Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <kamranahmed.se@gmail.com>
- Loading branch information
1 parent
4db353e
commit dccbe68
Showing
11 changed files
with
553 additions
and
172 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<UserStreamActivity | null>(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 ( | ||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8"> | ||
<h2 className="mb-3 text-xs uppercase text-gray-400"> | ||
Learning Activity | ||
</h2> | ||
|
||
{selectedActivity && ( | ||
<ActivityTopicsModal | ||
onClose={() => 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 ? ( | ||
<ul className="divide-y divide-gray-100"> | ||
{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 = ( | ||
<a | ||
className="font-medium underline transition-colors hover:cursor-pointer hover:text-black" | ||
target="_blank" | ||
href={resourceUrl} | ||
> | ||
{resourceTitle} | ||
</a> | ||
); | ||
|
||
const topicCount = topicIds?.length || 0; | ||
|
||
const timeAgo = ( | ||
<span className="ml-1 text-xs text-gray-400"> | ||
{getRelativeTimeString(new Date(updatedAt).toISOString())} | ||
</span> | ||
); | ||
|
||
return ( | ||
<li key={_id} className="py-2 text-sm text-gray-600"> | ||
{actionType === 'in_progress' && ( | ||
<> | ||
Started{' '} | ||
<button | ||
className="font-medium underline underline-offset-2 hover:text-black" | ||
onClick={() => setSelectedActivity(activity)} | ||
> | ||
{topicCount} topic{topicCount > 1 ? 's' : ''} | ||
</button>{' '} | ||
in {resourceLinkComponent} {timeAgo} | ||
</> | ||
)} | ||
{actionType === 'done' && ( | ||
<> | ||
Completed{' '} | ||
<button | ||
className="font-medium underline underline-offset-2 hover:text-black" | ||
onClick={() => setSelectedActivity(activity)} | ||
> | ||
{topicCount} topic{topicCount > 1 ? 's' : ''} | ||
</button>{' '} | ||
in {resourceLinkComponent} {timeAgo} | ||
</> | ||
)} | ||
{actionType === 'answered' && ( | ||
<> | ||
Answered {topicCount} question{topicCount > 1 ? 's' : ''} in{' '} | ||
{resourceLinkComponent} {timeAgo} | ||
</> | ||
)} | ||
</li> | ||
); | ||
})} | ||
</ul> | ||
) : ( | ||
<EmptyStream /> | ||
)} | ||
|
||
{activities.length > 10 && ( | ||
<button | ||
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white" | ||
onClick={() => setShowAll(!showAll)} | ||
> | ||
{showAll ? <> | ||
<ChevronsUp size={14} /> | ||
Show less | ||
</> : <> | ||
<ChevronsDown size={14} /> | ||
Show all | ||
</>} | ||
</button> | ||
)} | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<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 flex-col gap-1"> | ||
{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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { List } from 'lucide-react'; | ||
|
||
export function EmptyStream() { | ||
return ( | ||
<div className="rounded-md"> | ||
<div className="flex flex-col items-center p-7 text-center"> | ||
<List className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]" /> | ||
|
||
<h2 className="text-lg font-bold sm:text-xl">No Activities</h2> | ||
<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 | ||
<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> | ||
</div> | ||
</div> | ||
); | ||
} |
Oops, something went wrong.