From dccbe683fd5730e9dd5a9bb3917ea841aee38bb9 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Mon, 22 Apr 2024 22:19:34 +0600 Subject: [PATCH] feat: implement activity stream (#5485) * wip: implement activity stream * feat: add empty stream * fix: filter empty topic ids * fix: update progress group * fix: update icon * feat: add topic titles * fix: update topic title * fix: update http call * Redesign activity stream * Add activity stream --------- Co-authored-by: Kamran Ahmed --- src/components/Activity/ActivityPage.tsx | 20 ++- src/components/Activity/ActivityStream.tsx | 163 ++++++++++++++++++ .../Activity/ActivityTopicsModal.tsx | 136 +++++++++++++++ src/components/Activity/EmptyStream.tsx | 31 ++++ src/components/Activity/ResourceProgress.tsx | 133 +++----------- .../Activity/ResourceProgressActions.tsx | 132 ++++++++++++++ src/components/UserProgress/ModalLoader.tsx | 38 ++++ .../UserProgress/ProgressLoadingError.tsx | 37 ---- .../UserProgress/UserCustomProgressModal.tsx | 10 +- .../UserProgress/UserProgressModal.tsx | 10 +- .../UserPublicProgresses.tsx | 15 -- 11 files changed, 553 insertions(+), 172 deletions(-) create mode 100644 src/components/Activity/ActivityStream.tsx create mode 100644 src/components/Activity/ActivityTopicsModal.tsx create mode 100644 src/components/Activity/EmptyStream.tsx create mode 100644 src/components/Activity/ResourceProgressActions.tsx create mode 100644 src/components/UserProgress/ModalLoader.tsx delete mode 100644 src/components/UserProgress/ProgressLoadingError.tsx diff --git a/src/components/Activity/ActivityPage.tsx b/src/components/Activity/ActivityPage.tsx index 482fd27e5..1ff5b51c0 100644 --- a/src/components/Activity/ActivityPage.tsx +++ b/src/components/Activity/ActivityPage.tsx @@ -4,6 +4,7 @@ import { ActivityCounters } from './ActivityCounters'; import { ResourceProgress } from './ResourceProgress'; import { pageProgressMessage } from '../../stores/page'; import { EmptyActivity } from './EmptyActivity'; +import { ActivityStream, type UserStreamActivity } from './ActivityStream'; type ProgressResponse = { updatedAt: string; @@ -45,6 +46,7 @@ export type ActivityResponse = { resourceTitle?: string; }; }[]; + activities: UserStreamActivity[]; }; export function ActivityPage() { @@ -96,8 +98,13 @@ export function ActivityPage() { return updatedAtB.getTime() - updatedAtA.getTime(); }) - .filter((bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0); + .filter( + (bestPractice) => bestPractice.learning > 0 || bestPractice.done > 0, + ); + const hasProgress = + learningRoadmapsToShow.length !== 0 || + learningBestPracticesToShow.length !== 0; return ( <> @@ -107,16 +114,17 @@ export function ActivityPage() { streak={activity?.streak || { count: 0 }} /> -
+
{learningRoadmapsToShow.length === 0 && learningBestPracticesToShow.length === 0 && } - {(learningRoadmapsToShow.length > 0 || learningBestPracticesToShow.length > 0) && ( + {(learningRoadmapsToShow.length > 0 || + learningBestPracticesToShow.length > 0) && ( <>

Continue Following

-
+
{learningRoadmaps .sort((a, b) => { const updatedAtA = new Date(a.updatedAt); @@ -192,6 +200,10 @@ export function ActivityPage() { )}
+ + {hasProgress && ( + + )} ); } diff --git a/src/components/Activity/ActivityStream.tsx b/src/components/Activity/ActivityStream.tsx new file mode 100644 index 000000000..531fdeccd --- /dev/null +++ b/src/components/Activity/ActivityStream.tsx @@ -0,0 +1,163 @@ +import { useMemo, useState } from 'react'; +import { getRelativeTimeString } from '../../lib/date'; +import type { ResourceType } from '../../lib/resource-progress'; +import { EmptyStream } from './EmptyStream'; +import { ActivityTopicsModal } from './ActivityTopicsModal.tsx'; +import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react'; + +export const allowedActivityActionType = [ + 'in_progress', + 'done', + 'answered', +] as const; +export type AllowedActivityActionType = + (typeof allowedActivityActionType)[number]; + +export type UserStreamActivity = { + _id?: string; + resourceType: ResourceType | 'question'; + resourceId: string; + resourceTitle: string; + resourceSlug?: string; + isCustomResource?: boolean; + actionType: AllowedActivityActionType; + topicIds?: string[]; + createdAt: Date; + updatedAt: Date; +}; + +type ActivityStreamProps = { + activities: UserStreamActivity[]; +}; + +export function ActivityStream(props: ActivityStreamProps) { + const { activities } = props; + + const [showAll, setShowAll] = useState(false); + const [selectedActivity, setSelectedActivity] = + useState(null); + + const sortedActivities = activities + .filter((activity) => activity?.topicIds && activity.topicIds.length > 0) + .sort((a, b) => { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }) + .slice(0, showAll ? activities.length : 10); + + return ( +
+

+ Learning Activity +

+ + {selectedActivity && ( + setSelectedActivity(null)} + activityId={selectedActivity._id!} + resourceId={selectedActivity.resourceId} + resourceType={selectedActivity.resourceType} + isCustomResource={selectedActivity.isCustomResource} + topicIds={selectedActivity.topicIds || []} + topicCount={selectedActivity.topicIds?.length || 0} + actionType={selectedActivity.actionType} + /> + )} + + {activities.length > 0 ? ( +
    + {sortedActivities.map((activity) => { + const { + _id, + resourceType, + resourceId, + resourceTitle, + actionType, + updatedAt, + topicIds, + isCustomResource, + } = activity; + + const resourceUrl = + resourceType === 'question' + ? `/questions/${resourceId}` + : resourceType === 'best-practice' + ? `/best-practices/${resourceId}` + : isCustomResource && resourceType === 'roadmap' + ? `/r/${resourceId}` + : `/${resourceId}`; + + const resourceLinkComponent = ( + + {resourceTitle} + + ); + + const topicCount = topicIds?.length || 0; + + const timeAgo = ( + + {getRelativeTimeString(new Date(updatedAt).toISOString())} + + ); + + return ( +
  • + {actionType === 'in_progress' && ( + <> + Started{' '} + {' '} + in {resourceLinkComponent} {timeAgo} + + )} + {actionType === 'done' && ( + <> + Completed{' '} + {' '} + in {resourceLinkComponent} {timeAgo} + + )} + {actionType === 'answered' && ( + <> + Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '} + {resourceLinkComponent} {timeAgo} + + )} +
  • + ); + })} +
+ ) : ( + + )} + + {activities.length > 10 && ( + + )} +
+ ); +} diff --git a/src/components/Activity/ActivityTopicsModal.tsx b/src/components/Activity/ActivityTopicsModal.tsx new file mode 100644 index 000000000..dcafa3e20 --- /dev/null +++ b/src/components/Activity/ActivityTopicsModal.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; +import type { ResourceType } from '../../lib/resource-progress'; +import type { AllowedActivityActionType } from './ActivityStream'; +import { httpPost } from '../../lib/http'; +import { Modal } from '../Modal.tsx'; +import { ModalLoader } from '../UserProgress/ModalLoader.tsx'; +import { ArrowUpRight, BookOpen, Check } from 'lucide-react'; + +type ActivityTopicDetailsProps = { + activityId: string; + resourceId: string; + resourceType: ResourceType | 'question'; + isCustomResource?: boolean; + topicIds: string[]; + topicCount: number; + actionType: AllowedActivityActionType; + onClose: () => void; +}; + +export function ActivityTopicsModal(props: ActivityTopicDetailsProps) { + const { + resourceId, + resourceType, + isCustomResource, + topicIds = [], + topicCount, + actionType, + onClose, + } = props; + + const [isLoading, setIsLoading] = useState(true); + const [topicTitles, setTopicTitles] = useState>({}); + const [error, setError] = useState(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 ( + + ); + } + + let pageUrl = ''; + if (resourceType === 'roadmap') { + pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`; + } else if (resourceType === 'best-practice') { + pageUrl = `/best-practices/${resourceId}`; + } else { + pageUrl = `/questions/${resourceId}`; + } + + return ( + { + onClose(); + setError(null); + setIsLoading(false); + }} + > +
+ + + {actionType.replace('_', ' ')} + + + Visit Page{' '} + + + +
    + {topicIds.map((topicId) => { + const topicTitle = topicTitles[topicId] || 'Unknown Topic'; + + const ActivityIcon = + actionType === 'done' + ? Check + : actionType === 'in_progress' + ? BookOpen + : Check; + + return ( +
  • + + {topicTitle} +
  • + ); + })} +
+
+
+ ); +} diff --git a/src/components/Activity/EmptyStream.tsx b/src/components/Activity/EmptyStream.tsx new file mode 100644 index 000000000..847ab7d34 --- /dev/null +++ b/src/components/Activity/EmptyStream.tsx @@ -0,0 +1,31 @@ +import { List } from 'lucide-react'; + +export function EmptyStream() { + return ( +
+
+ + +

No Activities

+

+ Activities will appear here as you start tracking your  + + Roadmaps + + ,  + + Best Practices + +  or  + + Questions + +  progress. +

+
+
+ ); +} diff --git a/src/components/Activity/ResourceProgress.tsx b/src/components/Activity/ResourceProgress.tsx index 536528f98..05953c842 100644 --- a/src/components/Activity/ResourceProgress.tsx +++ b/src/components/Activity/ResourceProgress.tsx @@ -1,9 +1,6 @@ -import { httpPost } from '../../lib/http'; -import { getRelativeTimeString } from '../../lib/date'; -import { useToast } from '../../hooks/use-toast'; -import { ProgressShareButton } from '../UserProgress/ProgressShareButton'; -import { useState } from 'react'; import { getUser } from '../../lib/jwt'; +import { getPercentage } from '../../helper/number'; +import { ResourceProgressActions } from './ResourceProgressActions'; type ResourceProgressType = { resourceType: 'roadmap' | 'best-practice'; @@ -22,9 +19,6 @@ type ResourceProgressType = { export function ResourceProgress(props: ResourceProgressType) { const { showClearButton = true, isCustomResource } = props; - const toast = useToast(); - const [isClearing, setIsClearing] = useState(false); - const [isConfirming, setIsConfirming] = useState(false); const userId = getUser()?.id; @@ -41,33 +35,6 @@ export function ResourceProgress(props: ResourceProgressType) { roadmapSlug, } = props; - async function clearProgress() { - setIsClearing(true); - const { error, response } = await httpPost( - `${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`, - { - resourceId, - resourceType, - }, - ); - - if (error || !response) { - toast.error('Error clearing progress. Please try again.'); - console.error(error); - setIsClearing(false); - return; - } - - localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`); - localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`); - - setIsClearing(false); - setIsConfirming(false); - if (onCleared) { - onCleared(); - } - } - let url = resourceType === 'roadmap' ? `/${resourceId}` @@ -78,95 +45,37 @@ export function ResourceProgress(props: ResourceProgressType) { } const totalMarked = doneCount + skippedCount; - const progressPercentage = Math.round((totalMarked / totalCount) * 100); + const progressPercentage = getPercentage(totalMarked, totalCount); return ( -
+
+ {title} + + {parseInt(progressPercentage, 10)}% + + - - {title} - - - {getRelativeTimeString(updatedAt)} - -
- - {doneCount > 0 && ( - <> - {doneCount} done • - - )} - {learningCount > 0 && ( - <> - {learningCount} in progress • - - )} - {skippedCount > 0 && ( - <> - {skippedCount} skipped • - - )} - {totalCount} total - -
- - - - {showClearButton && ( - <> - {!isConfirming && ( - - )} - {isConfirming && ( - - Are you sure?{' '} - {' '} - - - )} - - )} -
+
+
); diff --git a/src/components/Activity/ResourceProgressActions.tsx b/src/components/Activity/ResourceProgressActions.tsx new file mode 100644 index 000000000..7d67373e9 --- /dev/null +++ b/src/components/Activity/ResourceProgressActions.tsx @@ -0,0 +1,132 @@ +import { MoreVertical, X } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useKeydown } from '../../hooks/use-keydown'; +import { ProgressShareButton } from '../UserProgress/ProgressShareButton'; +import type { ResourceType } from '../../lib/resource-progress'; +import { httpPost } from '../../lib/http'; +import { useToast } from '../../hooks/use-toast'; + +type ResourceProgressActionsType = { + userId: string; + resourceType: ResourceType; + resourceId: string; + isCustomResource: boolean; + showClearButton?: boolean; + onCleared?: () => void; +}; + +export function ResourceProgressActions(props: ResourceProgressActionsType) { + const { + userId, + resourceType, + resourceId, + isCustomResource, + showClearButton = true, + onCleared, + } = props; + + const toast = useToast(); + const dropdownRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [isClearing, setIsClearing] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + async function clearProgress() { + setIsClearing(true); + const { error, response } = await httpPost( + `${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`, + { + resourceId, + resourceType, + }, + ); + + if (error || !response) { + toast.error('Error clearing progress. Please try again.'); + console.error(error); + setIsClearing(false); + return; + } + + localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`); + localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`); + + setIsClearing(false); + setIsConfirming(false); + if (onCleared) { + onCleared(); + } + } + + useOutsideClick(dropdownRef, () => { + setIsOpen(false); + }); + + useKeydown('Escape', () => { + setIsOpen(false); + }); + + return ( +
+ + + {isOpen && ( +
+ + {showClearButton && ( + <> + {!isConfirming && ( + + )} + + {isConfirming && ( + + Are you sure? +
+ + +
+
+ )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/UserProgress/ModalLoader.tsx b/src/components/UserProgress/ModalLoader.tsx new file mode 100644 index 000000000..27e62869e --- /dev/null +++ b/src/components/UserProgress/ModalLoader.tsx @@ -0,0 +1,38 @@ +import { ErrorIcon } from '../ReactIcons/ErrorIcon'; +import { Spinner } from '../ReactIcons/Spinner'; + +type ModalLoaderProps = { + isLoading: boolean; + error?: string; + text: string; +}; + +export function ModalLoader(props: ModalLoaderProps) { + const { isLoading, text, error } = props; + + return ( +
+
+
+
+ {isLoading && ( + <> + + + {text || 'Loading...'} + + + )} + + {error && ( + <> + + {error} + + )} +
+
+
+
+ ); +} diff --git a/src/components/UserProgress/ProgressLoadingError.tsx b/src/components/UserProgress/ProgressLoadingError.tsx deleted file mode 100644 index b7ce8bedc..000000000 --- a/src/components/UserProgress/ProgressLoadingError.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { ErrorIcon } from "../ReactIcons/ErrorIcon"; -import { Spinner } from "../ReactIcons/Spinner"; - -type ProgressLoadingErrorProps = { - isLoading: boolean; - error: string; -} - -export function ProgressLoadingError(props: ProgressLoadingErrorProps) { - const { isLoading, error } = props; - - return ( -
-
-
-
- {isLoading && ( - <> - - - Loading user progress... - - - )} - - {error && ( - <> - - {error} - - )} -
-
-
-
- ) -} \ No newline at end of file diff --git a/src/components/UserProgress/UserCustomProgressModal.tsx b/src/components/UserProgress/UserCustomProgressModal.tsx index b907c0396..75a2488f0 100644 --- a/src/components/UserProgress/UserCustomProgressModal.tsx +++ b/src/components/UserProgress/UserCustomProgressModal.tsx @@ -8,7 +8,7 @@ import { deleteUrlParam, getUrlParams } from '../../lib/browser'; import { useAuth } from '../../hooks/use-auth'; import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap'; import { ReadonlyEditor } from '../../../editor/readonly-editor'; -import { ProgressLoadingError } from './ProgressLoadingError'; +import { ModalLoader } from './ModalLoader.tsx'; import { UserProgressModalHeader } from './UserProgressModalHeader'; import { X } from 'lucide-react'; @@ -144,7 +144,13 @@ export function UserCustomProgressModal(props: ProgressMapProps) { } if (isLoading || error) { - return ; + return ( + + ); } return ( diff --git a/src/components/UserProgress/UserProgressModal.tsx b/src/components/UserProgress/UserProgressModal.tsx index 5a03e2fc3..3c7072ab0 100644 --- a/src/components/UserProgress/UserProgressModal.tsx +++ b/src/components/UserProgress/UserProgressModal.tsx @@ -8,7 +8,7 @@ import type { ResourceType } from '../../lib/resource-progress'; import { topicSelectorAll } from '../../lib/resource-progress'; import { deleteUrlParam, getUrlParams } from '../../lib/browser'; import { useAuth } from '../../hooks/use-auth'; -import { ProgressLoadingError } from './ProgressLoadingError'; +import { ModalLoader } from './ModalLoader.tsx'; import { UserProgressModalHeader } from './UserProgressModalHeader'; import { X } from 'lucide-react'; @@ -187,7 +187,13 @@ export function UserProgressModal(props: ProgressMapProps) { } if (isLoading || error) { - return ; + return ( + + ); } return ( diff --git a/src/components/UserPublicProfile/UserPublicProgresses.tsx b/src/components/UserPublicProfile/UserPublicProgresses.tsx index ce5756270..1eac8e296 100644 --- a/src/components/UserPublicProfile/UserPublicProgresses.tsx +++ b/src/components/UserPublicProfile/UserPublicProgresses.tsx @@ -25,21 +25,6 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) { (roadmap) => roadmap.isCustomResource, ); - // - return (
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (