mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-14 05:04:02 +02:00
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>
This commit is contained in:
@@ -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 }}
|
||||
/>
|
||||
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div className="mx-0 px-0 py-5 pb-0 md:-mx-10 md:px-8 md:py-8 md:pb-0">
|
||||
{learningRoadmapsToShow.length === 0 &&
|
||||
learningBestPracticesToShow.length === 0 && <EmptyActivity />}
|
||||
|
||||
{(learningRoadmapsToShow.length > 0 || learningBestPracticesToShow.length > 0) && (
|
||||
{(learningRoadmapsToShow.length > 0 ||
|
||||
learningBestPracticesToShow.length > 0) && (
|
||||
<>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Continue Following
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -192,6 +200,10 @@ export function ActivityPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasProgress && (
|
||||
<ActivityStream activities={activity?.activities || []} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
163
src/components/Activity/ActivityStream.tsx
Normal file
163
src/components/Activity/ActivityStream.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
136
src/components/Activity/ActivityTopicsModal.tsx
Normal file
136
src/components/Activity/ActivityTopicsModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
31
src/components/Activity/EmptyStream.tsx
Normal file
31
src/components/Activity/EmptyStream.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
@@ -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 (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<a
|
||||
target="_blank"
|
||||
href={url}
|
||||
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
|
||||
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 pr-7 text-left text-sm transition-all hover:border-gray-400"
|
||||
>
|
||||
<span className="flex-grow truncate">{title}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{parseInt(progressPercentage, 10)}%
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
|
||||
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
<span className="relative flex-1 cursor-pointer truncate">
|
||||
{title}
|
||||
</span>
|
||||
<span className="ml-1 cursor-pointer text-sm text-gray-400">
|
||||
{getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</a>
|
||||
<div className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<span className="hidden flex-1 gap-1 sm:flex">
|
||||
{doneCount > 0 && (
|
||||
<>
|
||||
<span>{doneCount} done</span> •
|
||||
</>
|
||||
)}
|
||||
{learningCount > 0 && (
|
||||
<>
|
||||
<span>{learningCount} in progress</span> •
|
||||
</>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<>
|
||||
<span>{skippedCount} skipped</span> •
|
||||
</>
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
|
||||
<ProgressShareButton
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
className="text-xs font-normal"
|
||||
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
||||
checkIconClassName="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className={'hidden sm:block'}>•</span>
|
||||
|
||||
{showClearButton && (
|
||||
<>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 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>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||
<ResourceProgressActions
|
||||
userId={userId!}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
onCleared={onCleared}
|
||||
showClearButton={showClearButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
132
src/components/Activity/ResourceProgressActions.tsx
Normal file
132
src/components/Activity/ResourceProgressActions.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
38
src/components/UserProgress/ModalLoader.tsx
Normal file
38
src/components/UserProgress/ModalLoader.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
@@ -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 <ProgressLoadingError isLoading={isLoading} error={error || ''} />;
|
||||
return (
|
||||
<ModalLoader
|
||||
text={'Loading user progress..'}
|
||||
isLoading={isLoading}
|
||||
error={error || ''}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -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 <ProgressLoadingError isLoading={isLoading} error={error} />;
|
||||
return (
|
||||
<ModalLoader
|
||||
text={'Loading user progress..'}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@@ -25,21 +25,6 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
|
||||
(roadmap) => roadmap.isCustomResource,
|
||||
);
|
||||
|
||||
// <UserPublicProgressStats
|
||||
// updatedAt={roadmap.updatedAt}
|
||||
// title={roadmap.title}
|
||||
// totalCount={roadmap.total}
|
||||
// doneCount={roadmap.done}
|
||||
// learningCount={roadmap.learning}
|
||||
// skippedCount={roadmap.skipped}
|
||||
// resourceId={roadmap.id}
|
||||
// resourceType="roadmap"
|
||||
// roadmapSlug={roadmap.roadmapSlug}
|
||||
// username={username!}
|
||||
// isCustomResource={true}
|
||||
// userId={userId}
|
||||
// />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (
|
||||
|
Reference in New Issue
Block a user