diff --git a/src/components/Activity/ResourceProgress.tsx b/src/components/Activity/ResourceProgress.tsx index ff5535ad8..936e58a0b 100644 --- a/src/components/Activity/ResourceProgress.tsx +++ b/src/components/Activity/ResourceProgress.tsx @@ -2,6 +2,7 @@ import { useState } from 'preact/hooks'; import { httpPost } from '../../lib/http'; import { getRelativeTimeString } from '../../lib/date'; import { useToast } from '../../hooks/use-toast'; +import { ProgressShareButton } from '../UserProgress/ProgressShareButton'; type ResourceProgressType = { resourceType: 'roadmap' | 'best-practice'; @@ -88,7 +89,7 @@ export function ResourceProgress(props: ResourceProgressType) { {getRelativeTimeString(updatedAt)} -

+

{doneCount > 0 && ( <> @@ -107,44 +108,55 @@ export function ResourceProgress(props: ResourceProgressType) { )} {totalCount} total - {showClearButton && ( - <> - {!isConfirming && ( - - )} + {showClearButton && ( + <> + {!isConfirming && ( + {' '} - - - )} - - )} -

+ )} + + {isConfirming && ( + + Are you sure?{' '} + {' '} + + + )} + + )} +
+ ); } diff --git a/src/components/BestPracticeHint.astro b/src/components/BestPracticeHint.astro index fc0709fb9..dcc40f328 100644 --- a/src/components/BestPracticeHint.astro +++ b/src/components/BestPracticeHint.astro @@ -3,8 +3,9 @@ import ResourceProgressStats from './ResourceProgressStats.astro'; export interface Props { bestPracticeId: string; } +const { bestPracticeId } = Astro.props; ---
- +
diff --git a/src/components/ReactIcons/ShareIcon.tsx b/src/components/ReactIcons/ShareIcon.tsx new file mode 100644 index 000000000..7cd372298 --- /dev/null +++ b/src/components/ReactIcons/ShareIcon.tsx @@ -0,0 +1,24 @@ +import type { JSX } from "preact/jsx-runtime"; + +type ShareIconProps = JSX.SVGAttributes + +export function ShareIcon(props: ShareIconProps) { + return ( + + + + + + ); +} diff --git a/src/components/ResourceProgressStats.astro b/src/components/ResourceProgressStats.astro index 93e5fcf34..8cfc0682b 100644 --- a/src/components/ResourceProgressStats.astro +++ b/src/components/ResourceProgressStats.astro @@ -1,10 +1,14 @@ --- +import type { ResourceType } from '../lib/resource-progress'; import AstroIcon from './AstroIcon.astro'; +import { ProgressShareButton } from './UserProgress/ProgressShareButton'; export interface Props { + resourceId: string; + resourceType: ResourceType; isSecondaryBanner?: boolean; } -const { isSecondaryBanner = false } = Astro.props; +const { isSecondaryBanner = false, resourceId, resourceType } = Astro.props; ---
0% Done - 0 completed· - 0 in progress· - 0 skipped· - 0 Total + + + 0 of 0 Done +

- + + +
-

- + 0 of 0 Done - -

+ + + diff --git a/src/components/RoadmapHint.astro b/src/components/RoadmapHint.astro index 6eee26ef3..5396218f1 100644 --- a/src/components/RoadmapHint.astro +++ b/src/components/RoadmapHint.astro @@ -43,5 +43,5 @@ const roadmapTitle = ) } - + \ No newline at end of file diff --git a/src/components/UserProgress/ProgressShareButton.tsx b/src/components/UserProgress/ProgressShareButton.tsx new file mode 100644 index 000000000..7bbc0f5fc --- /dev/null +++ b/src/components/UserProgress/ProgressShareButton.tsx @@ -0,0 +1,68 @@ +import { useAuth } from '../../hooks/use-auth'; +import { useCopyText } from '../../hooks/use-copy-text'; +import type { ResourceType } from '../../lib/resource-progress'; +import { CheckIcon } from '../ReactIcons/CheckIcon'; +import { ShareIcon } from '../ReactIcons/ShareIcon'; + +type ProgressShareButtonProps = { + resourceId: string; + resourceType: ResourceType; + className?: string; + shareIconClassName?: string; + checkIconClassName?: string; +}; +export function ProgressShareButton(props: ProgressShareButtonProps) { + const { + resourceId, + resourceType, + className, + shareIconClassName, + checkIconClassName, + } = props; + + const user = useAuth(); + const { copyText, isCopied } = useCopyText(); + + function handleCopyLink() { + const isDev = import.meta.env.DEV; + const newUrl = new URL( + isDev ? 'http://localhost:3000' : 'https://roadmap.sh' + ); + + if (resourceType === 'roadmap') { + newUrl.pathname = `/${resourceId}`; + } else { + newUrl.pathname = `/best-practices/${resourceId}`; + } + + newUrl.searchParams.set('s', user?.id || ''); + copyText(newUrl.toString()); + } + + if (!user) { + return null; + } + + return ( + + ); +} diff --git a/src/components/UserProgress/UserProgressModal.tsx b/src/components/UserProgress/UserProgressModal.tsx new file mode 100644 index 000000000..ac184bd50 --- /dev/null +++ b/src/components/UserProgress/UserProgressModal.tsx @@ -0,0 +1,294 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import { wireframeJSONToSVG } from 'roadmap-renderer'; +import '../FrameRenderer/FrameRenderer.css'; +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useKeydown } from '../../hooks/use-keydown'; +import { httpGet } from '../../lib/http'; +import type { ResourceType } from '../../lib/resource-progress'; +import { topicSelectorAll } from '../../lib/resource-progress'; +import CloseIcon from '../../icons/close.svg'; +import { deleteUrlParam, getUrlParams } from '../../lib/browser'; +import { useAuth } from '../../hooks/use-auth'; +import { Spinner } from '../ReactIcons/Spinner'; +import { ErrorIcon } from '../ReactIcons/ErrorIcon'; + +export type ProgressMapProps = { + resourceId: string; + resourceType: ResourceType; +}; + +type UserProgressResponse = { + user: { + _id: string; + name: string; + }; + progress: { + total: number; + done: string[]; + learning: string[]; + skipped: string[]; + }; +}; + +export function UserProgressModal(props: ProgressMapProps) { + const { s: userId } = getUrlParams(); + const { resourceId, resourceType } = props; + + const resourceSvgEl = useRef(null); + const popupBodyEl = useRef(null); + + const currentUser = useAuth(); + if (!userId || currentUser?.id === userId) { + deleteUrlParam('s'); + return null; + } + + const [showModal, setShowModal] = useState(!!userId); + const [resourceSvg, setResourceSvg] = useState(null); + const [progressResponse, setProgressResponse] = + useState(); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + let resourceJsonUrl = import.meta.env.DEV + ? 'http://localhost:3000' + : 'https://roadmap.sh'; + if (resourceType === 'roadmap') { + resourceJsonUrl += `/${resourceId}.json`; + } else { + resourceJsonUrl += `/best-practices/${resourceId}.json`; + } + + async function getUserProgress( + userId: string, + resourceType: string, + resourceId: string + ): Promise { + const { error, response } = await httpGet( + `${ + import.meta.env.PUBLIC_API_URL + }/v1-get-user-progress/${userId}?resourceType=${resourceType}&resourceId=${resourceId}` + ); + + if (error || !response) { + throw error || new Error('Something went wrong. Please try again!'); + } + + return response; + } + + async function getRoadmapSVG( + jsonUrl: string + ): Promise { + const { error, response: roadmapJson } = await httpGet(jsonUrl); + if (error || !roadmapJson) { + throw error || new Error('Something went wrong. Please try again!'); + } + + return await wireframeJSONToSVG(roadmapJson, { + fontURL: '/fonts/balsamiq.woff2', + }); + } + + function onClose() { + deleteUrlParam('s'); + setError(''); + setShowModal(false); + } + + useKeydown('Escape', () => { + onClose(); + }); + + useOutsideClick(popupBodyEl, () => { + onClose(); + }); + + useEffect(() => { + if (!resourceJsonUrl || !resourceId || !resourceType) { + return; + } + + setIsLoading(true); + Promise.all([ + getRoadmapSVG(resourceJsonUrl), + getUserProgress(userId, resourceType, resourceId), + ]) + .then(([svg, user]) => { + if (!user || !svg) { + return; + } + + const { progress } = user; + const { done, learning, skipped } = progress || { + done: [], + learning: [], + skipped: [], + }; + + done?.forEach((topicId: string) => { + topicSelectorAll(topicId, svg).forEach((el) => { + el.classList.add('done'); + }); + }); + + learning?.forEach((topicId: string) => { + topicSelectorAll(topicId, svg).forEach((el) => { + el.classList.add('learning'); + }); + }); + + skipped?.forEach((topicId: string) => { + topicSelectorAll(topicId, svg).forEach((el) => { + el.classList.add('skipped'); + }); + }); + + svg.querySelectorAll('.clickable-group').forEach((el) => { + el.classList.remove('clickable-group'); + }); + + svg.querySelectorAll('[data-group-id]').forEach((el) => { + el.removeAttribute('data-group-id'); + }); + + setResourceSvg(svg); + setProgressResponse(user); + }) + .catch((err) => { + setError(err?.message || 'Something went wrong. Please try again!'); + }) + .finally(() => { + setIsLoading(false); + }); + }, [userId]); + + const user = progressResponse?.user; + const progress = progressResponse?.progress; + + const userProgressTotal = progress?.total || 0; + const userDone = progress?.done?.length || 0; + const progressPercentage = + Math.round((userDone / userProgressTotal) * 100) || 0; + const userLearning = progress?.learning?.length || 0; + const userSkipped = progress?.skipped?.length || 0; + + if (!showModal) { + return null; + } + + if (isLoading || error) { + return ( +
+
+
+
+ {isLoading && ( + <> + + + Loading user progress... + + + )} + + {error && ( + <> + + {error} + + )} +
+
+
+
+ ); + } + + return ( +
+
+
+
+
+

+ {user?.name}'s Progress +

+

+ You can close this popup and start tracking your progress. +

+
+

+ + {progressPercentage}% Done + + + + {userDone} of {userProgressTotal} done + +

+ +
+ +
+ + +
+
+
+ ); +} diff --git a/src/lib/resource-progress.ts b/src/lib/resource-progress.ts index acd29fba4..c6c8a136a 100644 --- a/src/lib/resource-progress.ts +++ b/src/lib/resource-progress.ts @@ -4,7 +4,12 @@ import { TOKEN_COOKIE_NAME } from './jwt'; import Element = astroHTML.JSX.Element; export type ResourceType = 'roadmap' | 'best-practice'; -export type ResourceProgressType = 'done' | 'learning' | 'pending' | 'skipped' | 'removed'; +export type ResourceProgressType = + | 'done' + | 'learning' + | 'pending' + | 'skipped' + | 'removed'; type TopicMeta = { topicId: string; @@ -110,8 +115,8 @@ export async function getResourceProgress( detail: { resourceType, resourceId, - isFavorite - } + isFavorite, + }, }) ); @@ -146,7 +151,7 @@ async function loadFreshProgress( resourceId, response?.done || [], response?.learning || [], - response?.skipped || [], + response?.skipped || [] ); // Dispatch event to update favorite status in the MarkFavorite component @@ -155,8 +160,8 @@ async function loadFreshProgress( detail: { resourceType, resourceId, - isFavorite: response.isFavorite - } + isFavorite: response.isFavorite, + }, }) ); @@ -168,7 +173,7 @@ export function setResourceProgress( resourceId: string, done: string[], learning: string[], - skipped: string[], + skipped: string[] ): void { localStorage.setItem( `${resourceType}-${resourceId}-progress`, @@ -181,19 +186,14 @@ export function setResourceProgress( ); } -export function renderTopicProgress( +export function topicSelectorAll( topicId: string, - topicProgress: ResourceProgressType -) { - const isLearning = topicProgress === 'learning'; - const isSkipped = topicProgress === 'skipped'; - const isDone = topicProgress === 'done'; - const isRemoved = topicProgress === 'removed'; - + parentElement: Document | SVGElement = document +): Element[] { const matchingElements: Element[] = []; // Elements having sort order in the beginning of the group id - document + parentElement .querySelectorAll(`[data-group-id$="-${topicId}"]`) .forEach((element: unknown) => { const foundGroupId = @@ -206,19 +206,33 @@ export function renderTopicProgress( }); // Elements with exact match of the topic id - document + parentElement .querySelectorAll(`[data-group-id="${topicId}"]`) .forEach((element) => { matchingElements.push(element); }); // Matching "check:XXXX" box of the topic - document + parentElement .querySelectorAll(`[data-group-id="check:${topicId}"]`) .forEach((element) => { matchingElements.push(element); }); + return matchingElements; +} + +export function renderTopicProgress( + topicId: string, + topicProgress: ResourceProgressType +) { + const isLearning = topicProgress === 'learning'; + const isSkipped = topicProgress === 'skipped'; + const isDone = topicProgress === 'done'; + const isRemoved = topicProgress === 'removed'; + + const matchingElements: Element[] = topicSelectorAll(topicId); + matchingElements.forEach((element) => { if (isDone) { element.classList.add('done'); @@ -239,7 +253,7 @@ export function renderTopicProgress( } export function clearResourceProgress() { - const clickableElements = document.querySelectorAll('.clickable-group') + const clickableElements = document.querySelectorAll('.clickable-group'); for (const clickableElement of clickableElements) { clickableElement.classList.remove('done', 'skipped', 'learning', 'removed'); } @@ -304,17 +318,21 @@ export function refreshProgressCounters() { '.clickable-group.removed' ).length; const totalItems = - totalClickable - externalLinks - roadmapSwitchers - checkBoxes - totalRemoved; + totalClickable - + externalLinks - + roadmapSwitchers - + checkBoxes - + totalRemoved; const totalDone = document.querySelectorAll('.clickable-group.done').length - totalCheckBoxesDone; - const totalLearning = document.querySelectorAll( - '.clickable-group.learning' - ).length - totalCheckBoxesLearning; - const totalSkipped = document.querySelectorAll( - '.clickable-group.skipped' - ).length - totalCheckBoxesSkipped; + const totalLearning = + document.querySelectorAll('.clickable-group.learning').length - + totalCheckBoxesLearning; + const totalSkipped = + document.querySelectorAll('.clickable-group.skipped').length - + totalCheckBoxesSkipped; const doneCountEls = document.querySelectorAll('[data-progress-done]'); if (doneCountEls.length > 0) { diff --git a/src/pages/[roadmapId]/index.astro b/src/pages/[roadmapId]/index.astro index 23c00e388..ea7677d27 100644 --- a/src/pages/[roadmapId]/index.astro +++ b/src/pages/[roadmapId]/index.astro @@ -7,6 +7,7 @@ import RoadmapHeader from '../../components/RoadmapHeader.astro'; import ShareIcons from '../../components/ShareIcons/ShareIcons.astro'; import { TopicDetail } from '../../components/TopicDetail/TopicDetail'; import UpcomingForm from '../../components/UpcomingForm.astro'; +import { UserProgressModal } from '../../components/UserProgress/UserProgressModal'; import BaseLayout from '../../layouts/BaseLayout.astro'; import { generateArticleSchema, @@ -117,6 +118,11 @@ if (roadmapFAQs.length) { } {roadmapData.isUpcoming && } + diff --git a/src/pages/best-practices/[bestPracticeId]/index.astro b/src/pages/best-practices/[bestPracticeId]/index.astro index 06addbc36..bbd9311e4 100644 --- a/src/pages/best-practices/[bestPracticeId]/index.astro +++ b/src/pages/best-practices/[bestPracticeId]/index.astro @@ -6,9 +6,10 @@ import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro'; import { TopicDetail } from '../../../components/TopicDetail/TopicDetail'; import UpcomingForm from '../../../components/UpcomingForm.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro'; +import { UserProgressModal } from '../../../components/UserProgress/UserProgressModal'; import { -BestPracticeFrontmatter, -getBestPracticeIds, + BestPracticeFrontmatter, + getBestPracticeIds, } from '../../../lib/best-pratice'; import { generateArticleSchema } from '../../../lib/jsonld-schema'; @@ -90,7 +91,6 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road @@ -106,5 +106,11 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road } + + {bestPracticeData.isUpcoming && } diff --git a/src/pages/team/index.astro b/src/pages/team/index.astro index 93b07c503..64b6ca422 100644 --- a/src/pages/team/index.astro +++ b/src/pages/team/index.astro @@ -1,6 +1,6 @@ --- import AccountSidebar from '../../components/AccountSidebar.astro'; -import { TeamsList } from '../../components/TeamsList.tsx'; +import { TeamsList } from '../../components/TeamsList'; import { ActivityPage } from '../../components/Activity/ActivityPage'; import AccountLayout from '../../layouts/AccountLayout.astro'; ---