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>pull/5542/head
parent
4db353e017
commit
dccbe683fd
11 changed files with 553 additions and 172 deletions
@ -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> |
||||
); |
||||
} |
@ -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> |
||||
); |
||||
} |
@ -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> |
||||
); |
||||
} |
@ -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<HTMLDivElement>(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 ( |
||||
<div className="relative h-full" ref={dropdownRef}> |
||||
<button |
||||
className="h-full text-gray-400 hover:text-gray-700" |
||||
onClick={() => setIsOpen(!isOpen)} |
||||
> |
||||
<MoreVertical size={16} /> |
||||
</button> |
||||
|
||||
{isOpen && ( |
||||
<div className="absolute right-0 top-8 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg"> |
||||
<ProgressShareButton |
||||
resourceType={resourceType} |
||||
resourceId={resourceId} |
||||
isCustomResource={isCustomResource} |
||||
className="w-full gap-1.5 p-2 hover:bg-gray-100" |
||||
/> |
||||
{showClearButton && ( |
||||
<> |
||||
{!isConfirming && ( |
||||
<button |
||||
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70" |
||||
onClick={() => setIsConfirming(true)} |
||||
disabled={isClearing} |
||||
> |
||||
{!isClearing ? ( |
||||
<> |
||||
<X className="h-3.5 w-3.5" /> |
||||
Clear Progress |
||||
</> |
||||
) : ( |
||||
'Processing...' |
||||
)} |
||||
</button> |
||||
)} |
||||
|
||||
{isConfirming && ( |
||||
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"> |
||||
Are you sure? |
||||
<div className="flex items-center gap-2"> |
||||
<button |
||||
onClick={clearProgress} |
||||
className="text-red-500 underline hover:text-red-800" |
||||
> |
||||
Yes |
||||
</button> |
||||
<button |
||||
onClick={() => setIsConfirming(false)} |
||||
className="text-red-500 underline hover:text-red-800" |
||||
> |
||||
No |
||||
</button> |
||||
</div> |
||||
</span> |
||||
)} |
||||
</> |
||||
)} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
@ -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 ( |
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"> |
||||
<div className="relative mx-auto flex h-full w-full items-center justify-center"> |
||||
<div className="popup-body relative rounded-lg bg-white p-5 shadow"> |
||||
<div className="flex items-center"> |
||||
{isLoading && ( |
||||
<> |
||||
<Spinner className="h-6 w-6" isDualRing={false} /> |
||||
<span className="ml-3 text-lg font-semibold"> |
||||
{text || 'Loading...'} |
||||
</span> |
||||
</> |
||||
)} |
||||
|
||||
{error && ( |
||||
<> |
||||
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" /> |
||||
<span className="ml-3 text-lg font-semibold">{error}</span> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -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 ( |
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"> |
||||
<div className="relative mx-auto flex h-full w-full items-center justify-center"> |
||||
<div className="popup-body relative rounded-lg bg-white p-5 shadow"> |
||||
<div className="flex items-center"> |
||||
{isLoading && ( |
||||
<> |
||||
<Spinner className="h-6 w-6" isDualRing={false} /> |
||||
<span className="ml-3 text-lg font-semibold"> |
||||
Loading user progress... |
||||
</span> |
||||
</> |
||||
)} |
||||
|
||||
{error && ( |
||||
<> |
||||
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" /> |
||||
<span className="ml-3 text-lg font-semibold">{error}</span> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) |
||||
} |
Loading…
Reference in new issue