mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-15 21:54:08 +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 { ResourceProgress } from './ResourceProgress';
|
||||||
import { pageProgressMessage } from '../../stores/page';
|
import { pageProgressMessage } from '../../stores/page';
|
||||||
import { EmptyActivity } from './EmptyActivity';
|
import { EmptyActivity } from './EmptyActivity';
|
||||||
|
import { ActivityStream, type UserStreamActivity } from './ActivityStream';
|
||||||
|
|
||||||
type ProgressResponse = {
|
type ProgressResponse = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -45,6 +46,7 @@ export type ActivityResponse = {
|
|||||||
resourceTitle?: string;
|
resourceTitle?: string;
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
|
activities: UserStreamActivity[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ActivityPage() {
|
export function ActivityPage() {
|
||||||
@@ -96,8 +98,13 @@ export function ActivityPage() {
|
|||||||
|
|
||||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -107,16 +114,17 @@ export function ActivityPage() {
|
|||||||
streak={activity?.streak || { count: 0 }}
|
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 &&
|
{learningRoadmapsToShow.length === 0 &&
|
||||||
learningBestPracticesToShow.length === 0 && <EmptyActivity />}
|
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">
|
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||||
Continue Following
|
Continue Following
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||||
{learningRoadmaps
|
{learningRoadmaps
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const updatedAtA = new Date(a.updatedAt);
|
const updatedAtA = new Date(a.updatedAt);
|
||||||
@@ -192,6 +200,10 @@ export function ActivityPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { getUser } from '../../lib/jwt';
|
||||||
|
import { getPercentage } from '../../helper/number';
|
||||||
|
import { ResourceProgressActions } from './ResourceProgressActions';
|
||||||
|
|
||||||
type ResourceProgressType = {
|
type ResourceProgressType = {
|
||||||
resourceType: 'roadmap' | 'best-practice';
|
resourceType: 'roadmap' | 'best-practice';
|
||||||
@@ -22,9 +19,6 @@ type ResourceProgressType = {
|
|||||||
|
|
||||||
export function ResourceProgress(props: ResourceProgressType) {
|
export function ResourceProgress(props: ResourceProgressType) {
|
||||||
const { showClearButton = true, isCustomResource } = props;
|
const { showClearButton = true, isCustomResource } = props;
|
||||||
const toast = useToast();
|
|
||||||
const [isClearing, setIsClearing] = useState(false);
|
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
|
||||||
|
|
||||||
const userId = getUser()?.id;
|
const userId = getUser()?.id;
|
||||||
|
|
||||||
@@ -41,33 +35,6 @@ export function ResourceProgress(props: ResourceProgressType) {
|
|||||||
roadmapSlug,
|
roadmapSlug,
|
||||||
} = props;
|
} = 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 =
|
let url =
|
||||||
resourceType === 'roadmap'
|
resourceType === 'roadmap'
|
||||||
? `/${resourceId}`
|
? `/${resourceId}`
|
||||||
@@ -78,95 +45,37 @@ export function ResourceProgress(props: ResourceProgressType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalMarked = doneCount + skippedCount;
|
const totalMarked = doneCount + skippedCount;
|
||||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
const progressPercentage = getPercentage(totalMarked, totalCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="relative">
|
||||||
<a
|
<a
|
||||||
|
target="_blank"
|
||||||
href={url}
|
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
|
<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={{
|
style={{
|
||||||
width: `${progressPercentage}%`,
|
width: `${progressPercentage}%`,
|
||||||
}}
|
}}
|
||||||
></span>
|
></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>
|
</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">
|
<div className="absolute right-2 top-0 flex h-full items-center">
|
||||||
{doneCount > 0 && (
|
<ResourceProgressActions
|
||||||
<>
|
userId={userId!}
|
||||||
<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}
|
resourceType={resourceType}
|
||||||
resourceId={resourceId}
|
resourceId={resourceId}
|
||||||
isCustomResource={isCustomResource}
|
isCustomResource={isCustomResource}
|
||||||
className="text-xs font-normal"
|
onCleared={onCleared}
|
||||||
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
showClearButton={showClearButton}
|
||||||
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>
|
</div>
|
||||||
</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 { useAuth } from '../../hooks/use-auth';
|
||||||
import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap';
|
import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap';
|
||||||
import { ReadonlyEditor } from '../../../editor/readonly-editor';
|
import { ReadonlyEditor } from '../../../editor/readonly-editor';
|
||||||
import { ProgressLoadingError } from './ProgressLoadingError';
|
import { ModalLoader } from './ModalLoader.tsx';
|
||||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
@@ -144,7 +144,13 @@ export function UserCustomProgressModal(props: ProgressMapProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading || error) {
|
if (isLoading || error) {
|
||||||
return <ProgressLoadingError isLoading={isLoading} error={error || ''} />;
|
return (
|
||||||
|
<ModalLoader
|
||||||
|
text={'Loading user progress..'}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error || ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -8,7 +8,7 @@ import type { ResourceType } from '../../lib/resource-progress';
|
|||||||
import { topicSelectorAll } from '../../lib/resource-progress';
|
import { topicSelectorAll } from '../../lib/resource-progress';
|
||||||
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||||
import { useAuth } from '../../hooks/use-auth';
|
import { useAuth } from '../../hooks/use-auth';
|
||||||
import { ProgressLoadingError } from './ProgressLoadingError';
|
import { ModalLoader } from './ModalLoader.tsx';
|
||||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
@@ -187,7 +187,13 @@ export function UserProgressModal(props: ProgressMapProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading || error) {
|
if (isLoading || error) {
|
||||||
return <ProgressLoadingError isLoading={isLoading} error={error} />;
|
return (
|
||||||
|
<ModalLoader
|
||||||
|
text={'Loading user progress..'}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@@ -25,21 +25,6 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
|
|||||||
(roadmap) => roadmap.isCustomResource,
|
(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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (
|
{customRoadmapVisibility !== 'none' && customRoadmaps?.length > 0 && (
|
||||||
|
Reference in New Issue
Block a user