From 78e4c38c97b8888407389aee805591ed84650e6e Mon Sep 17 00:00:00 2001 From: Kamran Ahmed Date: Wed, 12 Apr 2023 18:11:56 +0100 Subject: [PATCH] Add user progress tracking --- .../AuthenticationFlow/EmailLoginForm.tsx | 1 - .../AuthenticationFlow/ForgotPasswordForm.tsx | 1 - .../AuthenticationFlow/GitHubButton.tsx | 1 + .../AuthenticationFlow/GoogleButton.tsx | 1 + .../AuthenticationFlow/ResetPasswordForm.tsx | 1 - src/components/FrameRenderer/renderer.js | 28 ++- src/components/Setting/UpdatePasswordForm.tsx | 1 - src/components/Spinner.astro | 29 --- src/components/Spinner.tsx | 26 --- src/components/TopicDetail/TopicDetail.tsx | 212 ++++++++++++++++++ src/components/TopicOverlay/topic.js | 47 +--- src/hooks/use-keydown.ts | 16 ++ src/hooks/use-load-topic.ts | 30 +++ src/hooks/use-outside-click.ts | 20 ++ src/icons/check.svg | 6 +- src/icons/reset.svg | 8 +- src/lib/http.ts | 6 +- src/lib/jwt.ts | 7 + src/lib/progress-api.ts | 34 --- src/lib/roadmap-topic.ts | 14 +- src/lib/user-resource-progress.ts | 112 +++++++++ src/pages/[roadmapId]/index.astro | 36 ++- .../[bestPracticeId]/index.astro | 28 ++- 23 files changed, 481 insertions(+), 184 deletions(-) delete mode 100644 src/components/Spinner.astro delete mode 100644 src/components/Spinner.tsx create mode 100644 src/components/TopicDetail/TopicDetail.tsx create mode 100644 src/hooks/use-keydown.ts create mode 100644 src/hooks/use-load-topic.ts create mode 100644 src/hooks/use-outside-click.ts delete mode 100644 src/lib/progress-api.ts create mode 100644 src/lib/user-resource-progress.ts diff --git a/src/components/AuthenticationFlow/EmailLoginForm.tsx b/src/components/AuthenticationFlow/EmailLoginForm.tsx index 8a3b90827..99b632368 100644 --- a/src/components/AuthenticationFlow/EmailLoginForm.tsx +++ b/src/components/AuthenticationFlow/EmailLoginForm.tsx @@ -1,7 +1,6 @@ import Cookies from 'js-cookie'; import type { FunctionComponent } from 'preact'; import { useState } from 'preact/hooks'; -import Spinner from '../Spinner'; import { httpPost } from '../../lib/http'; import {TOKEN_COOKIE_NAME} from "../../lib/jwt"; diff --git a/src/components/AuthenticationFlow/ForgotPasswordForm.tsx b/src/components/AuthenticationFlow/ForgotPasswordForm.tsx index c493c75be..9ed5e4a37 100644 --- a/src/components/AuthenticationFlow/ForgotPasswordForm.tsx +++ b/src/components/AuthenticationFlow/ForgotPasswordForm.tsx @@ -1,5 +1,4 @@ import { useState } from 'preact/hooks'; -import Spinner from '../Spinner'; import { httpPost } from '../../lib/http'; export function ForgotPasswordForm() { diff --git a/src/components/AuthenticationFlow/GitHubButton.tsx b/src/components/AuthenticationFlow/GitHubButton.tsx index 5b6da04e9..0f6ada813 100644 --- a/src/components/AuthenticationFlow/GitHubButton.tsx +++ b/src/components/AuthenticationFlow/GitHubButton.tsx @@ -58,6 +58,7 @@ export function GitHubButton(props: GitHubButtonProps) { } localStorage.removeItem(GITHUB_REDIRECT_AT); + localStorage.removeItem(GITHUB_LAST_PAGE); Cookies.set(TOKEN_COOKIE_NAME, data.token); window.location.href = redirectUrl; } diff --git a/src/components/AuthenticationFlow/GoogleButton.tsx b/src/components/AuthenticationFlow/GoogleButton.tsx index bff3b7c3b..df45cc808 100644 --- a/src/components/AuthenticationFlow/GoogleButton.tsx +++ b/src/components/AuthenticationFlow/GoogleButton.tsx @@ -57,6 +57,7 @@ export function GoogleButton(props: GoogleButtonProps) { } localStorage.removeItem(GOOGLE_REDIRECT_AT); + localStorage.removeItem(GOOGLE_LAST_PAGE); Cookies.set(TOKEN_COOKIE_NAME, data.token); window.location.href = redirectUrl; } diff --git a/src/components/AuthenticationFlow/ResetPasswordForm.tsx b/src/components/AuthenticationFlow/ResetPasswordForm.tsx index 8ebbcf769..cbf77e6d4 100644 --- a/src/components/AuthenticationFlow/ResetPasswordForm.tsx +++ b/src/components/AuthenticationFlow/ResetPasswordForm.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'preact/hooks'; -import Spinner from '../Spinner'; import { httpPost } from '../../lib/http'; import Cookies from 'js-cookie'; import {TOKEN_COOKIE_NAME} from "../../lib/jwt"; diff --git a/src/components/FrameRenderer/renderer.js b/src/components/FrameRenderer/renderer.js index 4c2daf87a..a5e769f95 100644 --- a/src/components/FrameRenderer/renderer.js +++ b/src/components/FrameRenderer/renderer.js @@ -1,6 +1,7 @@ import { wireframeJSONToSVG } from 'roadmap-renderer'; -import { httpGet } from '../../lib/http'; -import { getUserResourceProgressApi } from '../../lib/progress-api'; +import Cookies from 'js-cookie'; +import { TOKEN_COOKIE_NAME } from '../../lib/jwt.ts'; +import { httpGet } from '../../lib/http.ts'; export class Renderer { constructor() { @@ -44,11 +45,19 @@ export class Renderer { return true; } - async topicToggleDone() { - const { response, error } = await getUserResourceProgressApi({ - resourceId: this.resourceId, - resourceType: this.resourceType, - }); + async loadProgress() { + const token = Cookies.get(TOKEN_COOKIE_NAME); + if (!token) { + return; + } + + const { response, error } = await httpGet( + `${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`, + { + resourceId: this.resourceId, + resourceType: this.resourceType, + } + ); if (!response) { console.error(error); @@ -75,8 +84,6 @@ export class Renderer { return null; } - console.log(this.resourceType, this.resourceId); - this.containerEl.innerHTML = this.loaderHTML; return Promise.all([ fetch(jsonUrl) @@ -102,7 +109,8 @@ export class Renderer { this.containerEl.innerHTML = `
${message}
`; }), - this.topicToggleDone(), + + this.loadProgress(), ]); } diff --git a/src/components/Setting/UpdatePasswordForm.tsx b/src/components/Setting/UpdatePasswordForm.tsx index 71513322d..8d1d2af00 100644 --- a/src/components/Setting/UpdatePasswordForm.tsx +++ b/src/components/Setting/UpdatePasswordForm.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'preact/hooks'; import Cookies from 'js-cookie'; -import Spinner from '../Spinner'; import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; import { httpGet, httpPost } from '../../lib/http'; diff --git a/src/components/Spinner.astro b/src/components/Spinner.astro deleted file mode 100644 index 3e35e962b..000000000 --- a/src/components/Spinner.astro +++ /dev/null @@ -1,29 +0,0 @@ ---- -const { ...props } = Astro.props; - -export type Props = astroHTML.JSX.HTMLAttributes & {}; ---- - -
- - Loading -
diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx deleted file mode 100644 index e3117ba1c..000000000 --- a/src/components/Spinner.tsx +++ /dev/null @@ -1,26 +0,0 @@ -export default function Spinner({ className }: { className?: string }) { - return ( -
- - - - - Loading -
- ); -} diff --git a/src/components/TopicDetail/TopicDetail.tsx b/src/components/TopicDetail/TopicDetail.tsx new file mode 100644 index 000000000..f78bccef2 --- /dev/null +++ b/src/components/TopicDetail/TopicDetail.tsx @@ -0,0 +1,212 @@ +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import SpinnerIcon from '../../icons/spinner.svg'; +import CheckIcon from '../../icons/check.svg'; +import ResetIcon from '../../icons/reset.svg'; +import CloseIcon from '../../icons/close.svg'; + +import { useOutsideClick } from '../../hooks/use-outside-click'; +import { useLoadTopic } from '../../hooks/use-load-topic'; +import { httpGet } from '../../lib/http'; +import { isLoggedIn } from '../../lib/jwt'; +import { + isTopicDone, + ResourceType, + toggleMarkTopicDone, +} from '../../lib/user-resource-progress'; +import { useKeydown } from '../../hooks/use-keydown'; + +export function TopicDetail() { + const [isActive, setIsActive] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [topicHtml, setTopicHtml] = useState(''); + + const [isDone, setIsDone] = useState(); + const [isUpdatingProgress, setIsUpdatingProgress] = useState(true); + + const isGuest = useMemo(() => !isLoggedIn(), []); + const topicRef = useRef(null); + + // Details of the currently loaded topic + const [topicId, setTopicId] = useState(''); + const [resourceId, setResourceId] = useState(''); + const [resourceType, setResourceType] = useState('roadmap'); + + const toggleResourceProgress = (isDone: boolean) => { + setIsUpdatingProgress(true); + toggleMarkTopicDone({ topicId, resourceId, resourceType }, isDone) + .then(() => { + setIsDone(isDone); + setIsActive(false); + }) + .catch(err => { + alert(err.message); + console.error(err); + }) + .finally(() => { + setIsUpdatingProgress(false); + }); + + console.log('toggle', isDone); + }; + + // Load the topic status when the topic detail is active + useEffect(() => { + if (!topicId || !resourceId || !resourceType) { + return; + } + + setIsUpdatingProgress(true); + isTopicDone({ topicId, resourceId, resourceType }) + .then((status: boolean) => { + setIsUpdatingProgress(false); + setIsDone(status); + }) + .catch(console.error); + }, [topicId, resourceId, resourceType]); + + // Close the topic detail when user clicks outside the topic detail + useOutsideClick(topicRef, () => { + setIsActive(false); + }); + + useKeydown('Escape', () => { + setIsActive(false); + }); + + // Load the topic detail when the topic detail is active + useLoadTopic(({ topicId, resourceType, resourceId }) => { + setIsLoading(true); + setIsActive(true); + + setTopicId(topicId); + setResourceType(resourceType); + setResourceId(resourceId); + + const topicPartial = topicId.replaceAll(':', '/'); + const topicUrl = + resourceType === 'roadmap' + ? `/${resourceId}/${topicPartial}` + : `/best-practices/${resourceId}/${topicPartial}`; + + httpGet( + topicUrl, + {}, + { + headers: { + Accept: 'text/html', + }, + } + ) + .then(({ response }) => { + if (!response) { + setError('Topic not found.'); + return; + } + + // It's full HTML with page body, head etc. + // We only need the inner HTML of the #main-content + const node = new DOMParser().parseFromString(response, 'text/html'); + const topicHtml = node?.getElementById('main-content')?.outerHTML || ''; + + setIsLoading(false); + setTopicHtml(topicHtml); + }) + .catch((err) => { + setError('Something went wrong. Please try again later.'); + setIsLoading(false); + }); + }); + + if (!isActive) { + return null; + } + + return ( +
+
+ {isLoading && ( +
+ Loading +
+ )} + + {!isLoading && !error && ( + <> + {/* Actions for the topic */} +
+ {isGuest && ( + + )} + + {!isGuest && ( + <> + {isUpdatingProgress && ( + + )} + {!isUpdatingProgress && !isDone && ( + + )} + + {!isUpdatingProgress && isDone && ( + + )} + + )} + + +
+ + {/* Topic Content */} +
+ + )} +
+
+
+ ); +} diff --git a/src/components/TopicOverlay/topic.js b/src/components/TopicOverlay/topic.js index 02b6c7cd2..680b55054 100644 --- a/src/components/TopicOverlay/topic.js +++ b/src/components/TopicOverlay/topic.js @@ -1,4 +1,3 @@ -import { toggleMarkResourceDoneApi } from '../../lib/progress-api.ts'; export class Topic { constructor() { this.overlayId = 'topic-overlay'; @@ -30,7 +29,6 @@ export class Topic { this.markAsDone = this.markAsDone.bind(this); this.markAsPending = this.markAsPending.bind(this); this.querySvgElementsByTopicId = this.querySvgElementsByTopicId.bind(this); - this.rightClickListener = this.rightClickListener.bind(this); this.isTopicDone = this.isTopicDone.bind(this); this.init = this.init.bind(this); @@ -64,33 +62,6 @@ export class Topic { return document.getElementById(this.overlayId); } - rightClickListener(e) { - console.log(e.detail); - const groupId = e.target?.closest('g')?.dataset?.groupId; - if (!groupId) { - return; - } - - e.preventDefault(); - - console.log( - 'Right click on topic', - groupId, - this.activeResourceId, - this.activeResourceType - ); - - if (this.isTopicDone(groupId)) { - this.markAsPending( - groupId, - this.activeResourceId, - this.activeResourceType - ); - } else { - this.markAsDone(groupId, this.activeResourceId, this.activeResourceType); - } - } - resetDOM(hideOverlay = false) { if (hideOverlay) { this.overlayEl.classList.add('hidden'); @@ -206,7 +177,6 @@ export class Topic { handleRoadmapTopicClick(e) { const { resourceId: roadmapId, topicId } = e.detail; - console.log(e.detail); if (!topicId || !roadmapId) { console.log('Missing topic or roadmap: ', e.detail); return; @@ -263,13 +233,7 @@ export class Topic { async markAsDone(topicId, resourceId, resourceType) { const updatedTopicId = topicId.replace(/^\d+-/, ''); - console.log('Marking as done: ', updatedTopicId, resourceId, resourceType); - - const { response, error } = await toggleMarkResourceDoneApi({ - resourceId, - topicId: updatedTopicId, - resourceType, - }); + const { response, error } = {}; if (response) { this.close(); @@ -284,11 +248,7 @@ export class Topic { async markAsPending(topicId, resourceId, resourceType) { const updatedTopicId = topicId.replace(/^\d+-/, ''); - const { response, error } = await toggleMarkResourceDoneApi({ - resourceId, - topicId: updatedTopicId, - resourceType, - }); + const { response, error } = {}; if (response) { this.close(); @@ -356,9 +316,8 @@ export class Topic { 'roadmap.topic.click', this.handleRoadmapTopicClick ); - window.addEventListener('click', this.handleOverlayClick); - window.addEventListener('contextmenu', this.rightClickListener); + window.addEventListener('click', this.handleOverlayClick); window.addEventListener('keydown', (e) => { if (e.key.toLowerCase() === 'escape') { this.close(); diff --git a/src/hooks/use-keydown.ts b/src/hooks/use-keydown.ts new file mode 100644 index 000000000..9a53c9254 --- /dev/null +++ b/src/hooks/use-keydown.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'preact/hooks'; + +export function useKeydown(keyName: string, callback: any) { + useEffect(() => { + const listener = (event: any) => { + if (event.key.toLowerCase() === keyName.toLowerCase()) { + callback(); + } + }; + + window.addEventListener('keydown', listener); + return () => { + window.removeEventListener('keydown', listener); + }; + }, []); +} diff --git a/src/hooks/use-load-topic.ts b/src/hooks/use-load-topic.ts new file mode 100644 index 000000000..f9603bf53 --- /dev/null +++ b/src/hooks/use-load-topic.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'preact/hooks'; +import type {ResourceType} from "../components/TopicDetail/TopicDetail"; + +type CallbackType = (data: { + resourceType: ResourceType; + resourceId: string; + topicId: string; +}) => void; + +export function useLoadTopic(callback: CallbackType) { + useEffect(() => { + function handleTopicClick(e: any) { + const { resourceType, resourceId, topicId } = e.detail; + + callback({ + resourceType, + resourceId, + topicId, + }); + } + + window.addEventListener(`roadmap.topic.click`, handleTopicClick); + window.addEventListener(`best-practice.topic.click`, handleTopicClick); + + return () => { + window.removeEventListener(`roadmap.topic.click`, handleTopicClick); + window.removeEventListener(`best-practice.topic.click`, handleTopicClick); + }; + }, []); +} diff --git a/src/hooks/use-outside-click.ts b/src/hooks/use-outside-click.ts new file mode 100644 index 000000000..f22b0130e --- /dev/null +++ b/src/hooks/use-outside-click.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'preact/hooks'; + +export function useOutsideClick(ref: any, callback: any) { + useEffect(() => { + const listener = (event: any) => { + const isClickedOutside = !ref?.current?.contains(event.target); + if (isClickedOutside) { + callback(); + } + }; + + document.addEventListener('mousedown', listener); + document.addEventListener('touchstart', listener); + + return () => { + document.removeEventListener('mousedown', listener); + document.removeEventListener('touchstart', listener); + }; + }, [ref]); +} diff --git a/src/icons/check.svg b/src/icons/check.svg index 896f56aec..fe7e41c6f 100644 --- a/src/icons/check.svg +++ b/src/icons/check.svg @@ -1,5 +1,3 @@ -
- - +
+ + +
diff --git a/src/pages/best-practices/[bestPracticeId]/index.astro b/src/pages/best-practices/[bestPracticeId]/index.astro index 46c156eaf..156aedd74 100644 --- a/src/pages/best-practices/[bestPracticeId]/index.astro +++ b/src/pages/best-practices/[bestPracticeId]/index.astro @@ -1,13 +1,15 @@ --- +import { TopicDetail } from '../../../components/TopicDetail/TopicDetail'; import BestPracticeHeader from '../../../components/BestPracticeHeader.astro'; -import CaptchaScripts from '../../../components/Captcha/CaptchaScripts.astro'; import FrameRenderer from '../../../components/FrameRenderer/FrameRenderer.astro'; import MarkdownFile from '../../../components/MarkdownFile.astro'; import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro'; -import TopicOverlay from '../../../components/TopicOverlay/TopicOverlay.astro'; import UpcomingForm from '../../../components/UpcomingForm.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro'; -import { BestPracticeFrontmatter, getBestPracticeIds } from '../../../lib/best-pratice'; +import { + BestPracticeFrontmatter, + getBestPracticeIds, +} from '../../../lib/best-pratice'; import { generateArticleSchema } from '../../../lib/jsonld-schema'; export async function getStaticPaths() { @@ -23,8 +25,11 @@ interface Params extends Record { } const { bestPracticeId } = Astro.params as Params; -const bestPracticeFile = await import(`../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`); -const bestPracticeData = bestPracticeFile.frontmatter as BestPracticeFrontmatter; +const bestPracticeFile = await import( + `../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md` +); +const bestPracticeData = + bestPracticeFile.frontmatter as BestPracticeFrontmatter; let jsonLdSchema = []; @@ -55,7 +60,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road jsonLd={jsonLdSchema} > - + { !bestPracticeData.isUpcoming && bestPracticeData.jsonUrl && ( -
+
- +