mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-26 02:25:35 +02:00
feat: implement team activity stream (#5565)
* wip * feat: implement team activity page * feat: add pagination * fix: add max height * Team activity updates * Remove invalid activityes * Team activity page * fix: team roadmap versions not working * Add team activity items --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
@@ -107,7 +107,7 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
|||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
|
||||||
{topicIds.map((topicId) => {
|
{topicIds.map((topicId) => {
|
||||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||||
|
|
||||||
|
@@ -42,6 +42,7 @@ function handleGuest() {
|
|||||||
'/account',
|
'/account',
|
||||||
'/team',
|
'/team',
|
||||||
'/team/progress',
|
'/team/progress',
|
||||||
|
'/team/activity',
|
||||||
'/team/roadmaps',
|
'/team/roadmaps',
|
||||||
'/team/new',
|
'/team/new',
|
||||||
'/team/members',
|
'/team/members',
|
||||||
|
@@ -15,7 +15,7 @@ export function Step4({ team }: Step4Props) {
|
|||||||
Your team has been created. Happy learning!
|
Your team has been created. Happy learning!
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href={`/team/progress?t=${team._id}`}
|
href={`/team/activity?t=${team._id}`}
|
||||||
className="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
className="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
||||||
>
|
>
|
||||||
View Team
|
View Team
|
||||||
|
@@ -1,20 +1,19 @@
|
|||||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||||
import { httpPost } from '../../lib/http';
|
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
|
import type {
|
||||||
|
ResourceProgressType,
|
||||||
|
ResourceType,
|
||||||
|
} from '../../lib/resource-progress';
|
||||||
import {
|
import {
|
||||||
refreshProgressCounters,
|
refreshProgressCounters,
|
||||||
renderResourceProgress,
|
renderResourceProgress,
|
||||||
renderTopicProgress,
|
renderTopicProgress,
|
||||||
updateResourceProgress,
|
updateResourceProgress,
|
||||||
} from '../../lib/resource-progress';
|
} from '../../lib/resource-progress';
|
||||||
import type {
|
|
||||||
ResourceProgressType,
|
|
||||||
ResourceType,
|
|
||||||
} from '../../lib/resource-progress';
|
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
import { showLoginPopup } from '../../lib/popup';
|
import { showLoginPopup } from '../../lib/popup';
|
||||||
import { replaceChildren } from '../../lib/dom.ts';
|
import { replaceChildren } from '../../lib/dom.ts';
|
||||||
import {setUrlParams} from "../../lib/browser.ts";
|
import { setUrlParams } from '../../lib/browser.ts';
|
||||||
|
|
||||||
export class Renderer {
|
export class Renderer {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -94,7 +93,6 @@ export class Renderer {
|
|||||||
})
|
})
|
||||||
.then((svg) => {
|
.then((svg) => {
|
||||||
replaceChildren(this.containerEl!, svg);
|
replaceChildren(this.containerEl!, svg);
|
||||||
// this.containerEl?.replaceChildren(svg);
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
return renderResourceProgress(
|
return renderResourceProgress(
|
||||||
@@ -143,7 +141,7 @@ export class Renderer {
|
|||||||
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
||||||
|
|
||||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||||
setUrlParams({ [type]: newJsonFileSlug! })
|
setUrlParams({ [type]: newJsonFileSlug! });
|
||||||
|
|
||||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||||
}
|
}
|
||||||
|
@@ -201,7 +201,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
|||||||
Team{' '}
|
Team{' '}
|
||||||
<a
|
<a
|
||||||
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
|
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
|
||||||
href={`/team/progress?t=${currentTeam?.id}`}
|
href={`/team/activity?t=${currentTeam?.id}`}
|
||||||
>
|
>
|
||||||
{teamName}
|
{teamName}
|
||||||
</a>
|
</a>
|
||||||
|
@@ -73,7 +73,7 @@ export function DropdownTeamList(props: DropdownTeamListProps) {
|
|||||||
if (team.status === 'invited') {
|
if (team.status === 'invited') {
|
||||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||||
} else if (team.status === 'joined') {
|
} else if (team.status === 'joined') {
|
||||||
pageLink = `/team/progress?t=${team._id}`;
|
pageLink = `/team/activity?t=${team._id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -47,7 +47,7 @@ export function NotificationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'accept') {
|
if (status === 'accept') {
|
||||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
window.location.href = `/team/activity?t=${response.teamId}`;
|
||||||
} else {
|
} else {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('refresh-notification', {
|
new CustomEvent('refresh-notification', {
|
||||||
|
@@ -75,7 +75,7 @@ export function RespondInviteForm() {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
window.location.href = `/team/activity?t=${response.teamId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoadingInvite) {
|
if (isLoadingInvite) {
|
||||||
@@ -106,7 +106,7 @@ export function RespondInviteForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container text-center">
|
<div className="container text-center">
|
||||||
<BuildingIcon className="mx-auto mb-4 mt-24 w-20 opacity-20" />
|
<BuildingIcon className="mx-auto mb-4 mt-24 w-20 h-20 opacity-20" />
|
||||||
|
|
||||||
<h2 className={'mb-1 text-2xl font-bold'}>Join Team</h2>
|
<h2 className={'mb-1 text-2xl font-bold'}>Join Team</h2>
|
||||||
<p className="mb-3 text-base leading-6 text-gray-600">
|
<p className="mb-3 text-base leading-6 text-gray-600">
|
||||||
@@ -139,7 +139,7 @@ export function RespondInviteForm() {
|
|||||||
pageProgressMessage.set('');
|
pageProgressMessage.set('');
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
className="flex-grow cursor-pointer rounded-lg hover:bg-gray-300 bg-gray-200 px-3 py-2 text-center"
|
||||||
>
|
>
|
||||||
Accept
|
Accept
|
||||||
</button>
|
</button>
|
||||||
@@ -150,7 +150,7 @@ export function RespondInviteForm() {
|
|||||||
pageProgressMessage.set('');
|
pageProgressMessage.set('');
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 px-3 py-2 text-white disabled:opacity-40"
|
className="flex-grow cursor-pointer rounded-lg bg-red-500 hover:bg-red-600 px-3 py-2 text-white disabled:opacity-40"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
|
195
src/components/TeamActivity/TeamActivityItem.tsx
Normal file
195
src/components/TeamActivity/TeamActivityItem.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { getRelativeTimeString } from '../../lib/date';
|
||||||
|
import type { TeamStreamActivity } from './TeamActivityPage';
|
||||||
|
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||||
|
|
||||||
|
type TeamActivityItemProps = {
|
||||||
|
onTopicClick?: (activity: TeamStreamActivity) => void;
|
||||||
|
user: {
|
||||||
|
activities: TeamStreamActivity[];
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string | undefined;
|
||||||
|
username?: string | undefined;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||||
|
const { user, onTopicClick } = props;
|
||||||
|
const { activities } = user;
|
||||||
|
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
const resourceLink = (activity: TeamStreamActivity) => {
|
||||||
|
const {
|
||||||
|
resourceId,
|
||||||
|
resourceTitle,
|
||||||
|
resourceType,
|
||||||
|
isCustomResource,
|
||||||
|
resourceSlug,
|
||||||
|
} = activity;
|
||||||
|
|
||||||
|
const resourceUrl =
|
||||||
|
resourceType === 'question'
|
||||||
|
? `/questions/${resourceId}`
|
||||||
|
: resourceType === 'best-practice'
|
||||||
|
? `/best-practices/${resourceId}`
|
||||||
|
: isCustomResource && resourceType === 'roadmap'
|
||||||
|
? `/r/${resourceSlug}`
|
||||||
|
: `/${resourceId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black"
|
||||||
|
target="_blank"
|
||||||
|
href={resourceUrl}
|
||||||
|
>
|
||||||
|
{resourceTitle}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeAgo = (date: string | Date) => (
|
||||||
|
<span className="ml-1 text-xs text-gray-400">
|
||||||
|
{getRelativeTimeString(new Date(date).toISOString())}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const userAvatar = user.avatar
|
||||||
|
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||||
|
: '/images/default-avatar.png';
|
||||||
|
|
||||||
|
const username = (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className="mr-1 inline-block h-5 w-5 rounded-full"
|
||||||
|
src={userAvatar}
|
||||||
|
alt={user.name}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{user?.name || 'Unknown'}</span>{' '}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activities.length === 1) {
|
||||||
|
const activity = activities[0];
|
||||||
|
const { actionType, topicIds } = activity;
|
||||||
|
const topicCount = topicIds?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={user._id}
|
||||||
|
className="flex items-center flex-wrap gap-1 rounded-md border px-2 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
{actionType === 'in_progress' && (
|
||||||
|
<>
|
||||||
|
{username} started{' '}
|
||||||
|
<button
|
||||||
|
className="font-medium underline underline-offset-2 hover:text-black"
|
||||||
|
onClick={() => onTopicClick?.(activity)}
|
||||||
|
>
|
||||||
|
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||||
|
</button>{' '}
|
||||||
|
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionType === 'done' && (
|
||||||
|
<>
|
||||||
|
{username} completed{' '}
|
||||||
|
<button
|
||||||
|
className="font-medium underline underline-offset-2 hover:text-black"
|
||||||
|
onClick={() => onTopicClick?.(activity)}
|
||||||
|
>
|
||||||
|
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||||
|
</button>{' '}
|
||||||
|
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{actionType === 'answered' && (
|
||||||
|
<>
|
||||||
|
{username} answered {topicCount} question
|
||||||
|
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
|
||||||
|
{timeAgo(activity.updatedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueResourcesCount = new Set(
|
||||||
|
activities.map((activity) => activity.resourceId),
|
||||||
|
).size;
|
||||||
|
|
||||||
|
const activityLimit = showAll ? activities.length : 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={user._id} className="rounded-md border overflow-hidden">
|
||||||
|
<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}{' '}
|
||||||
|
resources
|
||||||
|
</h3>
|
||||||
|
<div className="py-3">
|
||||||
|
<ul className="flex flex-col gap-2 ml-2 sm:ml-[36px]">
|
||||||
|
{activities.slice(0, activityLimit).map((activity) => {
|
||||||
|
const { actionType, topicIds } = activity;
|
||||||
|
const topicCount = topicIds?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={activity._id} className="text-sm text-gray-600">
|
||||||
|
{actionType === 'in_progress' && (
|
||||||
|
<>
|
||||||
|
Started{' '}
|
||||||
|
<button
|
||||||
|
className="font-medium underline underline-offset-2 hover:text-black"
|
||||||
|
onClick={() => onTopicClick?.(activity)}
|
||||||
|
>
|
||||||
|
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||||
|
</button>{' '}
|
||||||
|
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{actionType === 'done' && (
|
||||||
|
<>
|
||||||
|
Completed{' '}
|
||||||
|
<button
|
||||||
|
className="font-medium underline underline-offset-2 hover:text-black"
|
||||||
|
onClick={() => onTopicClick?.(activity)}
|
||||||
|
>
|
||||||
|
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||||
|
</button>{' '}
|
||||||
|
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{actionType === 'answered' && (
|
||||||
|
<>
|
||||||
|
Answered {topicCount} question
|
||||||
|
{topicCount > 1 ? 's' : ''} in {resourceLink(activity)}{' '}
|
||||||
|
{timeAgo(activity.updatedAt)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{activities.length > 5 && (
|
||||||
|
<button
|
||||||
|
className="mt-3 flex items-center gap-2 rounded-md border border-gray-300 p-1 text-xs uppercase tracking-wide text-gray-600 transition-colors hover:border-black hover:bg-black hover:text-white"
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
>
|
||||||
|
{showAll ? (
|
||||||
|
<>
|
||||||
|
<ChevronsUp size={14} />
|
||||||
|
Show less
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronsDown size={14} />
|
||||||
|
Show more
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
189
src/components/TeamActivity/TeamActivityPage.tsx
Normal file
189
src/components/TeamActivity/TeamActivityPage.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { getUrlParams } from '../../lib/browser';
|
||||||
|
import { httpGet } from '../../lib/http';
|
||||||
|
import type { ResourceType } from '../../lib/resource-progress';
|
||||||
|
import type { AllowedActivityActionType } from '../Activity/ActivityStream';
|
||||||
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
|
import { TeamActivityItem } from './TeamActivityItem';
|
||||||
|
import { TeamActivityTopicsModal } from './TeamActivityTopicsModal';
|
||||||
|
import { TeamEmptyStream } from './TeamEmptyStream';
|
||||||
|
import { Pagination } from '../Pagination/Pagination';
|
||||||
|
|
||||||
|
export type TeamStreamActivity = {
|
||||||
|
_id?: string;
|
||||||
|
resourceType: ResourceType | 'question';
|
||||||
|
resourceId: string;
|
||||||
|
resourceTitle: string;
|
||||||
|
resourceSlug?: string;
|
||||||
|
isCustomResource?: boolean;
|
||||||
|
actionType: AllowedActivityActionType;
|
||||||
|
topicIds?: string[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TeamActivityStreamDocument {
|
||||||
|
_id?: string;
|
||||||
|
teamId: string;
|
||||||
|
userId: string;
|
||||||
|
activity: TeamStreamActivity[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetTeamActivityResponse = {
|
||||||
|
data: {
|
||||||
|
users: {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
avatar?: string;
|
||||||
|
username?: string;
|
||||||
|
}[];
|
||||||
|
activities: TeamActivityStreamDocument[];
|
||||||
|
};
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
currPage: number;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamActivityPage() {
|
||||||
|
const { t: teamId } = getUrlParams();
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [selectedActivity, setSelectedActivity] =
|
||||||
|
useState<TeamStreamActivity | null>(null);
|
||||||
|
const [teamActivities, setTeamActivities] = useState<GetTeamActivityResponse>(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
users: [],
|
||||||
|
activities: [],
|
||||||
|
},
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
currPage: 1,
|
||||||
|
perPage: 21,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [currPage, setCurrPage] = useState(1);
|
||||||
|
|
||||||
|
const getTeamProgress = async (currPage: number = 1) => {
|
||||||
|
const { response, error } = await httpGet<GetTeamActivityResponse>(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-activity/${teamId}`,
|
||||||
|
{
|
||||||
|
currPage,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error || !response) {
|
||||||
|
toast.error(error?.message || 'Failed to get team activity');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTeamActivities(response);
|
||||||
|
setCurrPage(response.currPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!teamId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTeamProgress().then(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [teamId]);
|
||||||
|
|
||||||
|
const { users, activities } = teamActivities?.data;
|
||||||
|
const usersWithActivities = useMemo(() => {
|
||||||
|
const validActivities = activities.filter((activity) => {
|
||||||
|
return (
|
||||||
|
activity.activity.length > 0 &&
|
||||||
|
activity.activity.some((t) => (t?.topicIds?.length || 0) > 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return users
|
||||||
|
.map((user) => {
|
||||||
|
const userActivities = validActivities
|
||||||
|
.filter((activity) => activity.userId === user._id)
|
||||||
|
.flatMap((activity) => activity.activity)
|
||||||
|
.filter((activity) => (activity?.topicIds?.length || 0) > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
return (
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
activities: userActivities,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((user) => user.activities.length > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
return (
|
||||||
|
new Date(b.activities[0].updatedAt).getTime() -
|
||||||
|
new Date(a.activities[0].updatedAt).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [users, activities]);
|
||||||
|
|
||||||
|
if (!teamId) {
|
||||||
|
window.location.href = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedActivity && (
|
||||||
|
<TeamActivityTopicsModal
|
||||||
|
activity={selectedActivity}
|
||||||
|
onClose={() => setSelectedActivity(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usersWithActivities.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<h3 className="mb-4 flex w-full items-center justify-between text-xs uppercase text-gray-400">
|
||||||
|
Team Activity
|
||||||
|
</h3>
|
||||||
|
<ul className="mb-4 mt-2 flex flex-col gap-3">
|
||||||
|
{usersWithActivities.map((user) => {
|
||||||
|
return (
|
||||||
|
<TeamActivityItem
|
||||||
|
key={user._id}
|
||||||
|
user={user}
|
||||||
|
onTopicClick={setSelectedActivity}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currPage={currPage}
|
||||||
|
totalPages={teamActivities.totalPages}
|
||||||
|
totalCount={teamActivities.totalCount}
|
||||||
|
perPage={teamActivities.perPage}
|
||||||
|
onPageChange={(page) => {
|
||||||
|
setCurrPage(page);
|
||||||
|
pageProgressMessage.set('Loading...');
|
||||||
|
getTeamProgress(page).finally(() => {
|
||||||
|
pageProgressMessage.set('');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<TeamEmptyStream teamId={teamId} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
128
src/components/TeamActivity/TeamActivityTopicsModal.tsx
Normal file
128
src/components/TeamActivity/TeamActivityTopicsModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { httpPost } from '../../lib/http';
|
||||||
|
import { Modal } from '../Modal.tsx';
|
||||||
|
import { ModalLoader } from '../UserProgress/ModalLoader.tsx';
|
||||||
|
import { ArrowUpRight, BookOpen, Check } from 'lucide-react';
|
||||||
|
import type { TeamStreamActivity } from './TeamActivityPage.tsx';
|
||||||
|
|
||||||
|
type TeamActivityTopicsModalProps = {
|
||||||
|
activity: TeamStreamActivity;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||||
|
const { activity, onClose } = props;
|
||||||
|
const {
|
||||||
|
resourceId,
|
||||||
|
resourceType,
|
||||||
|
isCustomResource,
|
||||||
|
topicIds = [],
|
||||||
|
actionType,
|
||||||
|
} = activity;
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadTopicTitles = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const { response, error } = await httpPost(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
|
||||||
|
{
|
||||||
|
resourceId,
|
||||||
|
resourceType,
|
||||||
|
isCustomResource,
|
||||||
|
topicIds,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error || !response) {
|
||||||
|
setError(error?.message || 'Failed to load topic titles');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTopicTitles(response);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTopicTitles().finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading || error) {
|
||||||
|
return (
|
||||||
|
<ModalLoader
|
||||||
|
error={error!}
|
||||||
|
text={'Loading topics..'}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageUrl = '';
|
||||||
|
if (resourceType === 'roadmap') {
|
||||||
|
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
|
||||||
|
} else if (resourceType === 'best-practice') {
|
||||||
|
pageUrl = `/best-practices/${resourceId}`;
|
||||||
|
} else {
|
||||||
|
pageUrl = `/questions/${resourceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
|
||||||
|
<span className="mb-2 flex items-center justify-between text-lg font-semibold capitalize">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{actionType.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={pageUrl}
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-1 rounded-md border border-transparent py-0.5 pl-2 pr-1 text-sm font-normal text-gray-400 transition-colors hover:border-black hover:bg-black hover:text-white"
|
||||||
|
>
|
||||||
|
Visit Page{' '}
|
||||||
|
<ArrowUpRight
|
||||||
|
size={16}
|
||||||
|
strokeWidth={2}
|
||||||
|
className="relative top-px"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
|
||||||
|
{topicIds.map((topicId) => {
|
||||||
|
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||||
|
|
||||||
|
const ActivityIcon =
|
||||||
|
actionType === 'done'
|
||||||
|
? Check
|
||||||
|
: actionType === 'in_progress'
|
||||||
|
? BookOpen
|
||||||
|
: Check;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={topicId} className="flex items-start gap-2">
|
||||||
|
<ActivityIcon
|
||||||
|
strokeWidth={3}
|
||||||
|
className="relative top-[4px] text-green-500"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
{topicTitle}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
23
src/components/TeamActivity/TeamEmptyStream.tsx
Normal file
23
src/components/TeamActivity/TeamEmptyStream.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Activity, List, ListTodo } from 'lucide-react';
|
||||||
|
|
||||||
|
type TeamActivityItemProps = {
|
||||||
|
teamId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamEmptyStream(props: TeamActivityItemProps) {
|
||||||
|
const { teamId } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md">
|
||||||
|
<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]" />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Team activity will appear here once members start tracking their
|
||||||
|
progress.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -162,7 +162,7 @@ export function TeamDropdown() {
|
|||||||
if (team.status === 'invited') {
|
if (team.status === 'invited') {
|
||||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||||
} else if (team.status === 'joined') {
|
} else if (team.status === 'joined') {
|
||||||
pageLink = `/team/progress?t=${team._id}`;
|
pageLink = `/team/activity?t=${team._id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -57,7 +57,9 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
let resourceJsonUrl = 'https://roadmap.sh';
|
let resourceJsonUrl = import.meta.env.DEV
|
||||||
|
? 'http://localhost:3000'
|
||||||
|
: 'https://roadmap.sh';
|
||||||
if (resourceType === 'roadmap') {
|
if (resourceType === 'roadmap') {
|
||||||
resourceJsonUrl += `/${resourceId}.json`;
|
resourceJsonUrl += `/${resourceId}.json`;
|
||||||
} else {
|
} else {
|
||||||
|
@@ -9,7 +9,7 @@ import { SubmitFeedbackPopup } from './Feedback/SubmitFeedbackPopup';
|
|||||||
import { ChevronDownIcon } from './ReactIcons/ChevronDownIcon.tsx';
|
import { ChevronDownIcon } from './ReactIcons/ChevronDownIcon.tsx';
|
||||||
import { GroupIcon } from './ReactIcons/GroupIcon.tsx';
|
import { GroupIcon } from './ReactIcons/GroupIcon.tsx';
|
||||||
import { TeamProgressIcon } from './ReactIcons/TeamProgressIcon.tsx';
|
import { TeamProgressIcon } from './ReactIcons/TeamProgressIcon.tsx';
|
||||||
import { MapIcon, MessageCircle } from 'lucide-react';
|
import { BarChart2, MapIcon, MessageCircle } from 'lucide-react';
|
||||||
import { CogIcon } from './ReactIcons/CogIcon.tsx';
|
import { CogIcon } from './ReactIcons/CogIcon.tsx';
|
||||||
|
|
||||||
type TeamSidebarProps = {
|
type TeamSidebarProps = {
|
||||||
@@ -25,6 +25,12 @@ export function TeamSidebar({ activePageId, children }: TeamSidebarProps) {
|
|||||||
const { teamId } = useTeamId();
|
const { teamId } = useTeamId();
|
||||||
|
|
||||||
const sidebarLinks = [
|
const sidebarLinks = [
|
||||||
|
{
|
||||||
|
title: 'Activity',
|
||||||
|
href: `/team/activity?t=${teamId}`,
|
||||||
|
id: 'activity',
|
||||||
|
icon: BarChart2,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Progress',
|
title: 'Progress',
|
||||||
href: `/team/progress?t=${teamId}`,
|
href: `/team/progress?t=${teamId}`,
|
||||||
|
@@ -107,20 +107,29 @@ export function TeamVersions(props: TeamVersionsProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearResourceProgress();
|
clearResourceProgress();
|
||||||
|
|
||||||
|
// teams have customizations. Assigning #customized-roadmap to roadmapSvgWrap
|
||||||
|
// makes those customizations visible and removes extra boxes
|
||||||
|
const roadmapSvgWrap: HTMLElement =
|
||||||
|
document.getElementById('resource-svg-wrap')?.parentElement ||
|
||||||
|
document.createElement('div');
|
||||||
|
|
||||||
if (!selectedTeamVersion) {
|
if (!selectedTeamVersion) {
|
||||||
deleteUrlParam('t');
|
deleteUrlParam('t');
|
||||||
renderResourceProgress(resourceType, resourceId).then();
|
renderResourceProgress(resourceType, resourceId).then();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUrlParams({ t: selectedTeamVersion.team._id! });
|
roadmapSvgWrap.id = '';
|
||||||
|
} else {
|
||||||
|
setUrlParams({ t: selectedTeamVersion.team._id! });
|
||||||
|
|
||||||
renderResourceProgress(resourceType, resourceId).then(() => {
|
renderResourceProgress(resourceType, resourceId).then(() => {
|
||||||
selectedTeamVersion.config?.removed?.forEach((topic) => {
|
selectedTeamVersion.config?.removed?.forEach((topic) => {
|
||||||
renderTopicProgress(topic, 'removed');
|
renderTopicProgress(topic, 'removed');
|
||||||
|
});
|
||||||
|
refreshProgressCounters();
|
||||||
|
roadmapSvgWrap.id = 'customized-roadmap';
|
||||||
});
|
});
|
||||||
refreshProgressCounters();
|
}
|
||||||
});
|
|
||||||
}, [selectedTeamVersion]);
|
}, [selectedTeamVersion]);
|
||||||
|
|
||||||
if (isPreparing) {
|
if (isPreparing) {
|
||||||
|
@@ -64,7 +64,7 @@ export function TeamsList() {
|
|||||||
if (team.status === 'invited') {
|
if (team.status === 'invited') {
|
||||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||||
} else if (team.status === 'joined') {
|
} else if (team.status === 'joined') {
|
||||||
pageLink = `/team/progress?t=${team._id}`;
|
pageLink = `/team/activity?t=${team._id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -309,11 +309,11 @@ export async function renderResourceProgress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMatchingElements(
|
function getMatchingElements(
|
||||||
quries: string[],
|
queries: string[],
|
||||||
parentElement: Document | SVGElement | HTMLDivElement = document,
|
parentElement: Document | SVGElement | HTMLDivElement = document,
|
||||||
): Element[] {
|
): Element[] {
|
||||||
const matchingElements: Element[] = [];
|
const matchingElements: Element[] = [];
|
||||||
quries.forEach((query) => {
|
queries.forEach((query) => {
|
||||||
parentElement.querySelectorAll(query).forEach((element) => {
|
parentElement.querySelectorAll(query).forEach((element) => {
|
||||||
matchingElements.push(element);
|
matchingElements.push(element);
|
||||||
});
|
});
|
||||||
|
15
src/pages/team/activity.astro
Normal file
15
src/pages/team/activity.astro
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
import { TeamSidebar } from '../../components/TeamSidebar';
|
||||||
|
import { TeamActivityPage } from '../../components/TeamActivity/TeamActivityPage';
|
||||||
|
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AccountLayout
|
||||||
|
title='Team Activities'
|
||||||
|
noIndex={true}
|
||||||
|
initialLoadingMessage='Loading activity'
|
||||||
|
>
|
||||||
|
<TeamSidebar activePageId='activity' client:load>
|
||||||
|
<TeamActivityPage client:only='react' />
|
||||||
|
</TeamSidebar>
|
||||||
|
</AccountLayout>
|
@@ -4,8 +4,12 @@ import { TeamProgressPage } from '../../components/TeamProgress/TeamProgressPage
|
|||||||
import AccountLayout from '../../layouts/AccountLayout.astro';
|
import AccountLayout from '../../layouts/AccountLayout.astro';
|
||||||
---
|
---
|
||||||
|
|
||||||
<AccountLayout title='Team Progress' noIndex={true} initialLoadingMessage='Loading Progress'>
|
<AccountLayout
|
||||||
|
title='Team Progress'
|
||||||
|
noIndex={true}
|
||||||
|
initialLoadingMessage='Loading Progress'
|
||||||
|
>
|
||||||
<TeamSidebar activePageId='progress' client:load>
|
<TeamSidebar activePageId='progress' client:load>
|
||||||
<TeamProgressPage client:only="react" />
|
<TeamProgressPage client:only='react' />
|
||||||
</TeamSidebar>
|
</TeamSidebar>
|
||||||
</AccountLayout>
|
</AccountLayout>
|
||||||
|
Reference in New Issue
Block a user