From afe718ee0978c785901d097ba84e168de5d6cda0 Mon Sep 17 00:00:00 2001 From: Arik Chakma Date: Wed, 21 Jun 2023 02:50:18 +0600 Subject: [PATCH] Allow marking roadmaps and best practices as favorites (#4087) * chore: favorite icon * fix: hero progress mark favorit * chore: mark favorite * fix: mouse overflow * fix: popup redirect * Update favorites on homepage * Refactor favorite logic * Change icon location --------- Co-authored-by: Kamran Ahmed --- src/components/FeaturedItems/FavoriteIcon.tsx | 45 +++++++++ .../FeaturedItems/FeaturedItem.astro | 31 +++++-- src/components/FeaturedItems/MarkFavorite.tsx | 93 +++++++++++++++++++ src/components/HeroSection/EmptyProgress.tsx | 2 +- .../HeroSection/FavoriteRoadmaps.tsx | 22 ++++- src/components/HeroSection/ProgressList.tsx | 33 +++++-- src/components/Popup/popup.js | 1 + .../{HeroSection => ReactIcons}/CheckIcon.tsx | 0 src/components/ReactIcons/Spinner.tsx | 21 +++++ src/components/RoadmapHeader.astro | 1 + src/directives/client-authenticated.mjs | 2 - src/layouts/BaseLayout.astro | 2 + 12 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 src/components/FeaturedItems/FavoriteIcon.tsx create mode 100644 src/components/FeaturedItems/MarkFavorite.tsx rename src/components/{HeroSection => ReactIcons}/CheckIcon.tsx (100%) create mode 100644 src/components/ReactIcons/Spinner.tsx diff --git a/src/components/FeaturedItems/FavoriteIcon.tsx b/src/components/FeaturedItems/FavoriteIcon.tsx new file mode 100644 index 000000000..1e7d72a79 --- /dev/null +++ b/src/components/FeaturedItems/FavoriteIcon.tsx @@ -0,0 +1,45 @@ +type FavoriteIconProps = { + isFavorite?: boolean; +}; + +export function FavoriteIcon(props: FavoriteIconProps) { + const { isFavorite } = props; + + if (!isFavorite) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/src/components/FeaturedItems/FeaturedItem.astro b/src/components/FeaturedItems/FeaturedItem.astro index 0025bbe28..3389e8556 100644 --- a/src/components/FeaturedItems/FeaturedItem.astro +++ b/src/components/FeaturedItems/FeaturedItem.astro @@ -1,4 +1,6 @@ --- +import AstroIcon from '../AstroIcon.astro'; +import { MarkFavorite } from './MarkFavorite'; export interface FeaturedItemType { isUpcoming?: boolean; isNew?: boolean; @@ -20,16 +22,22 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props; ]} href={url} > - + {text} + + { isNew && ( - - - - + + + + New @@ -38,14 +46,17 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props; { isUpcoming && ( - - - - + + + + Upcoming ) } - + diff --git a/src/components/FeaturedItems/MarkFavorite.tsx b/src/components/FeaturedItems/MarkFavorite.tsx new file mode 100644 index 000000000..516d1513b --- /dev/null +++ b/src/components/FeaturedItems/MarkFavorite.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'preact/hooks'; +import { httpPatch } from '../../lib/http'; +import type { ResourceType } from '../../lib/resource-progress'; +import { isLoggedIn } from '../../lib/jwt'; +import { showLoginPopup } from '../../lib/popup'; +import { FavoriteIcon } from './FavoriteIcon'; +import { Spinner } from '../ReactIcons/Spinner'; + +type MarkFavoriteType = { + resourceType: ResourceType; + resourceId: string; + favorite?: boolean; +}; + +export function MarkFavorite({ resourceId, resourceType, favorite }: MarkFavoriteType) { + const [isLoading, setIsLoading] = useState(false); + const [isFavorite, setIsFavorite] = useState(favorite ?? false); + + async function toggleFavoriteHandler(e: Event) { + e.preventDefault(); + if (!isLoggedIn()) { + showLoginPopup(); + return; + } + + if (isLoading) { + return; + } + + setIsLoading(true); + + const { error } = await httpPatch<{ status: 'ok' }>( + `${import.meta.env.PUBLIC_API_URL}/v1-mark-favorite`, + { + resourceType, + resourceId, + } + ); + + if (error) { + setIsLoading(false); + return alert('Failed to update favorite status'); + } + + // Dispatching an event instead of setting the state because + // MarkFavorite component is used in the HeroSection as well + // as featured items section. We will let the custom event + // listener set the update `useEffect` + window.dispatchEvent( + new CustomEvent('mark-favorite', { + detail: { + resourceId, + resourceType, + isFavorite: !isFavorite, + }, + }) + ); + window.dispatchEvent(new CustomEvent('refresh-favorites', {})); + + setIsFavorite(!isFavorite); + setIsLoading(false); + } + + useEffect(() => { + const listener = (e: Event) => { + const { + resourceId: id, + resourceType: type, + isFavorite: fav, + } = (e as CustomEvent).detail; + if (id === resourceId && type === resourceType) { + setIsFavorite(fav); + } + }; + + window.addEventListener('mark-favorite', listener); + return () => { + window.removeEventListener('mark-favorite', listener); + }; + }, []); + + return ( + + ); +} diff --git a/src/components/HeroSection/EmptyProgress.tsx b/src/components/HeroSection/EmptyProgress.tsx index b90c3f1b4..9d98c05d7 100644 --- a/src/components/HeroSection/EmptyProgress.tsx +++ b/src/components/HeroSection/EmptyProgress.tsx @@ -1,4 +1,4 @@ -import { CheckIcon } from './CheckIcon'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; type EmptyProgressProps = { title?: string; diff --git a/src/components/HeroSection/FavoriteRoadmaps.tsx b/src/components/HeroSection/FavoriteRoadmaps.tsx index 1604b97ff..02c3a6d77 100644 --- a/src/components/HeroSection/FavoriteRoadmaps.tsx +++ b/src/components/HeroSection/FavoriteRoadmaps.tsx @@ -7,6 +7,7 @@ export type UserProgressResponse = { resourceId: string; resourceType: 'roadmap' | 'best-practice'; resourceTitle: string; + isFavorite: boolean; done: number; learning: number; skipped: number; @@ -25,6 +26,16 @@ function renderProgress(progressList: UserProgressResponse) { return; } + window.dispatchEvent( + new CustomEvent('mark-favorite', { + detail: { + resourceId: progress.resourceId, + resourceType: progress.resourceType, + isFavorite: progress.isFavorite, + }, + }) + ); + const totalDone = progress.done + progress.skipped; const percentageDone = (totalDone / progress.total) * 100; @@ -61,6 +72,7 @@ export function FavoriteRoadmaps() { async function loadProgress() { setIsLoading(true); + const { response: progressList, error } = await httpGet( `${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress` @@ -84,6 +96,11 @@ export function FavoriteRoadmaps() { }); }, []); + useEffect(() => { + window.addEventListener('refresh-favorites', loadProgress); + return () => window.removeEventListener('refresh-favorites', loadProgress); + }, []); + if (isPreparing) { return null; } @@ -97,10 +114,9 @@ export function FavoriteRoadmaps() { }`} >
- {isLoading && } {!isLoading && progress.length == 0 && } - {!isLoading && progress.length > 0 && ( - + {progress.length > 0 && ( + )}
diff --git a/src/components/HeroSection/ProgressList.tsx b/src/components/HeroSection/ProgressList.tsx index 6b7ad471b..3eec5cd8e 100644 --- a/src/components/HeroSection/ProgressList.tsx +++ b/src/components/HeroSection/ProgressList.tsx @@ -1,22 +1,31 @@ import type { UserProgressResponse } from './FavoriteRoadmaps'; -import { CheckIcon } from './CheckIcon'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { MarkFavorite } from '../FeaturedItems/MarkFavorite'; +import { Spinner } from '../ReactIcons/Spinner'; type ProgressListProps = { progress: UserProgressResponse; + isLoading?: boolean; }; export function ProgressList(props: ProgressListProps) { - const { progress } = props; + const { progress, isLoading = false } = props; return ( -
+

- - Your progress and favorite roadmaps. - Your progress and favorite roadmaps. + {!isLoading && ( + + )} + {isLoading && ( + + + + )} + Your progress and favorite roadmaps.

-
+
{progress.map((resource) => { const url = resource.resourceType === 'roadmap' @@ -28,15 +37,21 @@ export function ProgressList(props: ProgressListProps) { return ( - {resource.resourceTitle} + {resource.resourceTitle} + ); })} diff --git a/src/components/Popup/popup.js b/src/components/Popup/popup.js index 37b8f0128..6598cb736 100644 --- a/src/components/Popup/popup.js +++ b/src/components/Popup/popup.js @@ -19,6 +19,7 @@ export class Popup { return; } + e.preventDefault(); popupEl.classList.remove('hidden'); popupEl.classList.add('flex'); const focusEl = popupEl.querySelector('[autofocus]'); diff --git a/src/components/HeroSection/CheckIcon.tsx b/src/components/ReactIcons/CheckIcon.tsx similarity index 100% rename from src/components/HeroSection/CheckIcon.tsx rename to src/components/ReactIcons/CheckIcon.tsx diff --git a/src/components/ReactIcons/Spinner.tsx b/src/components/ReactIcons/Spinner.tsx new file mode 100644 index 000000000..d8026942b --- /dev/null +++ b/src/components/ReactIcons/Spinner.tsx @@ -0,0 +1,21 @@ +export function Spinner() { + return ( + + + + + ); +} diff --git a/src/components/RoadmapHeader.astro b/src/components/RoadmapHeader.astro index 5cbb0595f..79296ab05 100644 --- a/src/components/RoadmapHeader.astro +++ b/src/components/RoadmapHeader.astro @@ -2,6 +2,7 @@ import Icon from './AstroIcon.astro'; import LoginPopup from './AuthenticationFlow/LoginPopup.astro'; import RoadmapHint from './RoadmapHint.astro'; +import { MarkFavorite } from './FeaturedItems/MarkFavorite.tsx'; import RoadmapNote from './RoadmapNote.astro'; import TopicSearch from './TopicSearch/TopicSearch.astro'; import YouTubeAlert from './YouTubeAlert.astro'; diff --git a/src/directives/client-authenticated.mjs b/src/directives/client-authenticated.mjs index f76d42f64..1f46cd217 100644 --- a/src/directives/client-authenticated.mjs +++ b/src/directives/client-authenticated.mjs @@ -1,8 +1,6 @@ export default async (load, opts) => { const isAuthenticated = document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1; if (isAuthenticated) { - console.log("loading"); - const hydrate = await load(); await hydrate(); } diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index dca50f454..955e0e3b6 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,5 +1,6 @@ --- import Analytics from '../components/Analytics/Analytics.astro'; +import LoginPopup from '../components/AuthenticationFlow/LoginPopup.astro'; import Authenticator from '../components/Authenticator/Authenticator.astro'; import { CommandMenu } from '../components/CommandMenu/CommandMenu'; import Footer from '../components/Footer.astro'; @@ -149,6 +150,7 @@ const gaPageIdentifier = Astro.url.pathname +