mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-25 18:20:46 +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>
|
||||
</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) => {
|
||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||
|
||||
|
@@ -42,6 +42,7 @@ function handleGuest() {
|
||||
'/account',
|
||||
'/team',
|
||||
'/team/progress',
|
||||
'/team/activity',
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
|
@@ -15,7 +15,7 @@ export function Step4({ team }: Step4Props) {
|
||||
Your team has been created. Happy learning!
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
View Team
|
||||
|
@@ -1,20 +1,19 @@
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
import {
|
||||
refreshProgressCounters,
|
||||
renderResourceProgress,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import type {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { replaceChildren } from '../../lib/dom.ts';
|
||||
import {setUrlParams} from "../../lib/browser.ts";
|
||||
import { setUrlParams } from '../../lib/browser.ts';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
@@ -94,7 +93,6 @@ export class Renderer {
|
||||
})
|
||||
.then((svg) => {
|
||||
replaceChildren(this.containerEl!, svg);
|
||||
// this.containerEl?.replaceChildren(svg);
|
||||
})
|
||||
.then(() => {
|
||||
return renderResourceProgress(
|
||||
@@ -143,7 +141,7 @@ export class Renderer {
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
||||
|
||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||
setUrlParams({ [type]: newJsonFileSlug! })
|
||||
setUrlParams({ [type]: newJsonFileSlug! });
|
||||
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||
}
|
||||
|
@@ -201,7 +201,7 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
Team{' '}
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
|
@@ -73,7 +73,7 @@ export function DropdownTeamList(props: DropdownTeamListProps) {
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
pageLink = `/team/activity?t=${team._id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -47,7 +47,7 @@ export function NotificationPage() {
|
||||
}
|
||||
|
||||
if (status === 'accept') {
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
window.location.href = `/team/activity?t=${response.teamId}`;
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('refresh-notification', {
|
||||
|
@@ -75,7 +75,7 @@ export function RespondInviteForm() {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
window.location.href = `/team/activity?t=${response.teamId}`;
|
||||
}
|
||||
|
||||
if (isLoadingInvite) {
|
||||
@@ -106,7 +106,7 @@ export function RespondInviteForm() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<p className="mb-3 text-base leading-6 text-gray-600">
|
||||
@@ -139,7 +139,7 @@ export function RespondInviteForm() {
|
||||
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
|
||||
</button>
|
||||
@@ -150,7 +150,7 @@ export function RespondInviteForm() {
|
||||
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
|
||||
</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') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
pageLink = `/team/activity?t=${team._id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -57,7 +57,9 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
let resourceJsonUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
|
@@ -9,7 +9,7 @@ import { SubmitFeedbackPopup } from './Feedback/SubmitFeedbackPopup';
|
||||
import { ChevronDownIcon } from './ReactIcons/ChevronDownIcon.tsx';
|
||||
import { GroupIcon } from './ReactIcons/GroupIcon.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';
|
||||
|
||||
type TeamSidebarProps = {
|
||||
@@ -25,6 +25,12 @@ export function TeamSidebar({ activePageId, children }: TeamSidebarProps) {
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
title: 'Activity',
|
||||
href: `/team/activity?t=${teamId}`,
|
||||
id: 'activity',
|
||||
icon: BarChart2,
|
||||
},
|
||||
{
|
||||
title: 'Progress',
|
||||
href: `/team/progress?t=${teamId}`,
|
||||
|
@@ -107,20 +107,29 @@ export function TeamVersions(props: TeamVersionsProps) {
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
deleteUrlParam('t');
|
||||
renderResourceProgress(resourceType, resourceId).then();
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlParams({ t: selectedTeamVersion.team._id! });
|
||||
roadmapSvgWrap.id = '';
|
||||
} else {
|
||||
setUrlParams({ t: selectedTeamVersion.team._id! });
|
||||
|
||||
renderResourceProgress(resourceType, resourceId).then(() => {
|
||||
selectedTeamVersion.config?.removed?.forEach((topic) => {
|
||||
renderTopicProgress(topic, 'removed');
|
||||
renderResourceProgress(resourceType, resourceId).then(() => {
|
||||
selectedTeamVersion.config?.removed?.forEach((topic) => {
|
||||
renderTopicProgress(topic, 'removed');
|
||||
});
|
||||
refreshProgressCounters();
|
||||
roadmapSvgWrap.id = 'customized-roadmap';
|
||||
});
|
||||
refreshProgressCounters();
|
||||
});
|
||||
}
|
||||
}, [selectedTeamVersion]);
|
||||
|
||||
if (isPreparing) {
|
||||
|
@@ -64,7 +64,7 @@ export function TeamsList() {
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
pageLink = `/team/activity?t=${team._id}`;
|
||||
}
|
||||
|
||||
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(
|
||||
quries: string[],
|
||||
queries: string[],
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document,
|
||||
): Element[] {
|
||||
const matchingElements: Element[] = [];
|
||||
quries.forEach((query) => {
|
||||
queries.forEach((query) => {
|
||||
parentElement.querySelectorAll(query).forEach((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';
|
||||
---
|
||||
|
||||
<AccountLayout title='Team Progress' noIndex={true} initialLoadingMessage='Loading Progress'>
|
||||
<AccountLayout
|
||||
title='Team Progress'
|
||||
noIndex={true}
|
||||
initialLoadingMessage='Loading Progress'
|
||||
>
|
||||
<TeamSidebar activePageId='progress' client:load>
|
||||
<TeamProgressPage client:only="react" />
|
||||
<TeamProgressPage client:only='react' />
|
||||
</TeamSidebar>
|
||||
</AccountLayout>
|
||||
|
Reference in New Issue
Block a user