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 <kamranahmed.se@gmail.com>
pull/4095/head
Arik Chakma 1 year ago committed by GitHub
parent 4aca01a98d
commit afe718ee09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 45
      src/components/FeaturedItems/FavoriteIcon.tsx
  2. 31
      src/components/FeaturedItems/FeaturedItem.astro
  3. 93
      src/components/FeaturedItems/MarkFavorite.tsx
  4. 2
      src/components/HeroSection/EmptyProgress.tsx
  5. 22
      src/components/HeroSection/FavoriteRoadmaps.tsx
  6. 33
      src/components/HeroSection/ProgressList.tsx
  7. 1
      src/components/Popup/popup.js
  8. 0
      src/components/ReactIcons/CheckIcon.tsx
  9. 21
      src/components/ReactIcons/Spinner.tsx
  10. 1
      src/components/RoadmapHeader.astro
  11. 2
      src/directives/client-authenticated.mjs
  12. 2
      src/layouts/BaseLayout.astro

@ -0,0 +1,45 @@
type FavoriteIconProps = {
isFavorite?: boolean;
};
export function FavoriteIcon(props: FavoriteIconProps) {
const { isFavorite } = props;
if (!isFavorite) {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5ZM5.93682 1.25C6.42732 1.25 6.83382 1.632 6.86382 2.122L7.24932 8.506C7.25216 8.55018 7.24229 8.59425 7.22089 8.63301C7.19949 8.67176 7.16745 8.70359 7.12854 8.72472C7.08964 8.74585 7.0455 8.75542 7.00134 8.75228C6.95718 8.74914 6.91484 8.73343 6.87932 8.707L4.27582 6.783C4.19591 6.72397 4.09917 6.69211 3.99982 6.69211C3.90047 6.69211 3.80373 6.72397 3.72382 6.783L1.11982 8.707C1.0843 8.73343 1.04196 8.74914 0.9978 8.75228C0.953639 8.75542 0.909502 8.74585 0.8706 8.72472C0.831697 8.70359 0.799653 8.67176 0.778252 8.63301C0.756851 8.59425 0.746986 8.55018 0.749822 8.506L1.13632 2.122C1.16632 1.632 1.57232 1.25 2.06282 1.25H5.93682Z"
fill="currentColor"
/>
</svg>
);
}
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
className="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5Z"
fill="currentColor"
/>
</svg>
);
}

@ -1,4 +1,6 @@
--- ---
import AstroIcon from '../AstroIcon.astro';
import { MarkFavorite } from './MarkFavorite';
export interface FeaturedItemType { export interface FeaturedItemType {
isUpcoming?: boolean; isUpcoming?: boolean;
isNew?: boolean; isNew?: boolean;
@ -20,16 +22,22 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
]} ]}
href={url} href={url}
> >
<span class='text-slate-400 relative z-20'> <span class='relative z-20 text-slate-400'>
{text} {text}
</span> </span>
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
client:load
/>
{ {
isNew && ( isNew && (
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-purple-300 flex items-center'> <span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
<span class='flex h-2 w-2 mr-1.5'> <span class='mr-1.5 flex h-2 w-2'>
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-purple-400 opacity-75' /> <span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex rounded-full h-2 w-2 bg-purple-500' /> <span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
</span> </span>
New New
</span> </span>
@ -38,14 +46,17 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
{ {
isUpcoming && ( isUpcoming && (
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-slate-500 flex items-center'> <span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-slate-500'>
<span class='flex h-2 w-2 mr-1.5'> <span class='mr-1.5 flex h-2 w-2'>
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-slate-500 opacity-75' /> <span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-slate-500 opacity-75' />
<span class='relative inline-flex rounded-full h-2 w-2 bg-slate-600' /> <span class='relative inline-flex h-2 w-2 rounded-full bg-slate-600' />
</span> </span>
Upcoming Upcoming
</span> </span>
) )
} }
<span data-progress class="z-10 bg-[#172a3a] absolute top-0 left-0 bottom-0 duration-300 transition-[width] w-0"></span> <span
data-progress
class='absolute bottom-0 left-0 top-0 z-10 w-0 bg-[#172a3a] transition-[width] duration-300'
></span>
</a> </a>

@ -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 (
<button
onClick={toggleFavoriteHandler}
tabIndex={-1}
className={`${
isFavorite ? '' : 'opacity-30 hover:opacity-100'
} absolute right-1.5 top-1.5 z-30 focus:outline-0`}
>
{isLoading ? <Spinner /> : <FavoriteIcon isFavorite={isFavorite} />}
</button>
);
}

@ -1,4 +1,4 @@
import { CheckIcon } from './CheckIcon'; import { CheckIcon } from '../ReactIcons/CheckIcon';
type EmptyProgressProps = { type EmptyProgressProps = {
title?: string; title?: string;

@ -7,6 +7,7 @@ export type UserProgressResponse = {
resourceId: string; resourceId: string;
resourceType: 'roadmap' | 'best-practice'; resourceType: 'roadmap' | 'best-practice';
resourceTitle: string; resourceTitle: string;
isFavorite: boolean;
done: number; done: number;
learning: number; learning: number;
skipped: number; skipped: number;
@ -25,6 +26,16 @@ function renderProgress(progressList: UserProgressResponse) {
return; return;
} }
window.dispatchEvent(
new CustomEvent('mark-favorite', {
detail: {
resourceId: progress.resourceId,
resourceType: progress.resourceType,
isFavorite: progress.isFavorite,
},
})
);
const totalDone = progress.done + progress.skipped; const totalDone = progress.done + progress.skipped;
const percentageDone = (totalDone / progress.total) * 100; const percentageDone = (totalDone / progress.total) * 100;
@ -61,6 +72,7 @@ export function FavoriteRoadmaps() {
async function loadProgress() { async function loadProgress() {
setIsLoading(true); setIsLoading(true);
const { response: progressList, error } = const { response: progressList, error } =
await httpGet<UserProgressResponse>( await httpGet<UserProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress` `${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) { if (isPreparing) {
return null; return null;
} }
@ -97,10 +114,9 @@ export function FavoriteRoadmaps() {
}`} }`}
> >
<div className="container min-h-full"> <div className="container min-h-full">
{isLoading && <EmptyProgress title="Loading progress .." />}
{!isLoading && progress.length == 0 && <EmptyProgress />} {!isLoading && progress.length == 0 && <EmptyProgress />}
{!isLoading && progress.length > 0 && ( {progress.length > 0 && (
<ProgressList progress={progress} /> <ProgressList progress={progress} isLoading={isLoading} />
)} )}
</div> </div>
</div> </div>

@ -1,22 +1,31 @@
import type { UserProgressResponse } from './FavoriteRoadmaps'; 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 = { type ProgressListProps = {
progress: UserProgressResponse; progress: UserProgressResponse;
isLoading?: boolean;
}; };
export function ProgressList(props: ProgressListProps) { export function ProgressList(props: ProgressListProps) {
const { progress } = props; const { progress, isLoading = false } = props;
return ( return (
<div className="relative pt-4 sm:pt-7 pb-12"> <div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-4 flex items-center text-sm text-gray-400"> <p className="mb-4 flex items-center text-sm text-gray-400">
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} /> {!isLoading && (
<span className='hidden sm:inline'>Your progress and favorite roadmaps.</span> <CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
<span className='inline sm:hidden'>Your progress and favorite roadmaps.</span> )}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
Your progress and favorite roadmaps.
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => { {progress.map((resource) => {
const url = const url =
resource.resourceType === 'roadmap' resource.resourceType === 'roadmap'
@ -28,15 +37,21 @@ export function ProgressList(props: ProgressListProps) {
return ( return (
<a <a
key={resource.resourceId}
href={url} href={url}
className="relative flex flex-col rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300 overflow-hidden" className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
> >
<span className='relative z-20'>{resource.resourceTitle}</span> <span className="relative z-20">{resource.resourceTitle}</span>
<span <span
class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]" class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }} style={{ width: `${percentageDone}%` }}
></span> ></span>
<MarkFavorite
resourceId={resource.resourceId}
resourceType={resource.resourceType}
favorite={resource.isFavorite}
/>
</a> </a>
); );
})} })}

@ -19,6 +19,7 @@ export class Popup {
return; return;
} }
e.preventDefault();
popupEl.classList.remove('hidden'); popupEl.classList.remove('hidden');
popupEl.classList.add('flex'); popupEl.classList.add('flex');
const focusEl = popupEl.querySelector('[autofocus]'); const focusEl = popupEl.querySelector('[autofocus]');

@ -0,0 +1,21 @@
export function Spinner() {
return (
<svg
className="h-3.5 w-3.5 animate-spin"
viewBox="0 0 93 93"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
style="fill: #404040;"
></path>
<path
d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z"
style="fill: #94a3b8;"
></path>
</svg>
);
}

@ -2,6 +2,7 @@
import Icon from './AstroIcon.astro'; import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro'; import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import RoadmapHint from './RoadmapHint.astro'; import RoadmapHint from './RoadmapHint.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite.tsx';
import RoadmapNote from './RoadmapNote.astro'; import RoadmapNote from './RoadmapNote.astro';
import TopicSearch from './TopicSearch/TopicSearch.astro'; import TopicSearch from './TopicSearch/TopicSearch.astro';
import YouTubeAlert from './YouTubeAlert.astro'; import YouTubeAlert from './YouTubeAlert.astro';

@ -1,8 +1,6 @@
export default async (load, opts) => { export default async (load, opts) => {
const isAuthenticated = document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1; const isAuthenticated = document.cookie.toString().indexOf('__roadmapsh_jt__') !== -1;
if (isAuthenticated) { if (isAuthenticated) {
console.log("loading");
const hydrate = await load(); const hydrate = await load();
await hydrate(); await hydrate();
} }

@ -1,5 +1,6 @@
--- ---
import Analytics from '../components/Analytics/Analytics.astro'; import Analytics from '../components/Analytics/Analytics.astro';
import LoginPopup from '../components/AuthenticationFlow/LoginPopup.astro';
import Authenticator from '../components/Authenticator/Authenticator.astro'; import Authenticator from '../components/Authenticator/Authenticator.astro';
import { CommandMenu } from '../components/CommandMenu/CommandMenu'; import { CommandMenu } from '../components/CommandMenu/CommandMenu';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
@ -149,6 +150,7 @@ const gaPageIdentifier = Astro.url.pathname
</slot> </slot>
<Authenticator /> <Authenticator />
<LoginPopup />
<CommandMenu client:idle /> <CommandMenu client:idle />
<PageProgress initialMessage={initialLoadingMessage} client:idle /> <PageProgress initialMessage={initialLoadingMessage} client:idle />
<PageSponsor <PageSponsor

Loading…
Cancel
Save