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 & {};
----
-
-
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 (
-
- );
-}
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 && (
+
+
+
+ )}
+
+ {!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 @@
-