Add user progress tracking

pull/3813/head
Kamran Ahmed 2 years ago
parent f83cd701e9
commit 78e4c38c97
  1. 1
      src/components/AuthenticationFlow/EmailLoginForm.tsx
  2. 1
      src/components/AuthenticationFlow/ForgotPasswordForm.tsx
  3. 1
      src/components/AuthenticationFlow/GitHubButton.tsx
  4. 1
      src/components/AuthenticationFlow/GoogleButton.tsx
  5. 1
      src/components/AuthenticationFlow/ResetPasswordForm.tsx
  6. 24
      src/components/FrameRenderer/renderer.js
  7. 1
      src/components/Setting/UpdatePasswordForm.tsx
  8. 29
      src/components/Spinner.astro
  9. 26
      src/components/Spinner.tsx
  10. 212
      src/components/TopicDetail/TopicDetail.tsx
  11. 47
      src/components/TopicOverlay/topic.js
  12. 16
      src/hooks/use-keydown.ts
  13. 30
      src/hooks/use-load-topic.ts
  14. 20
      src/hooks/use-outside-click.ts
  15. 6
      src/icons/check.svg
  16. 8
      src/icons/reset.svg
  17. 6
      src/lib/http.ts
  18. 7
      src/lib/jwt.ts
  19. 34
      src/lib/progress-api.ts
  20. 14
      src/lib/roadmap-topic.ts
  21. 112
      src/lib/user-resource-progress.ts
  22. 36
      src/pages/[roadmapId]/index.astro
  23. 28
      src/pages/best-practices/[bestPracticeId]/index.astro

@ -1,7 +1,6 @@
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import type { FunctionComponent } from 'preact'; import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import Spinner from '../Spinner';
import { httpPost } from '../../lib/http'; import { httpPost } from '../../lib/http';
import {TOKEN_COOKIE_NAME} from "../../lib/jwt"; import {TOKEN_COOKIE_NAME} from "../../lib/jwt";

@ -1,5 +1,4 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import Spinner from '../Spinner';
import { httpPost } from '../../lib/http'; import { httpPost } from '../../lib/http';
export function ForgotPasswordForm() { export function ForgotPasswordForm() {

@ -58,6 +58,7 @@ export function GitHubButton(props: GitHubButtonProps) {
} }
localStorage.removeItem(GITHUB_REDIRECT_AT); localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, data.token); Cookies.set(TOKEN_COOKIE_NAME, data.token);
window.location.href = redirectUrl; window.location.href = redirectUrl;
} }

@ -57,6 +57,7 @@ export function GoogleButton(props: GoogleButtonProps) {
} }
localStorage.removeItem(GOOGLE_REDIRECT_AT); localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, data.token); Cookies.set(TOKEN_COOKIE_NAME, data.token);
window.location.href = redirectUrl; window.location.href = redirectUrl;
} }

@ -1,5 +1,4 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import Spinner from '../Spinner';
import { httpPost } from '../../lib/http'; import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import {TOKEN_COOKIE_NAME} from "../../lib/jwt"; import {TOKEN_COOKIE_NAME} from "../../lib/jwt";

@ -1,6 +1,7 @@
import { wireframeJSONToSVG } from 'roadmap-renderer'; import { wireframeJSONToSVG } from 'roadmap-renderer';
import { httpGet } from '../../lib/http'; import Cookies from 'js-cookie';
import { getUserResourceProgressApi } from '../../lib/progress-api'; import { TOKEN_COOKIE_NAME } from '../../lib/jwt.ts';
import { httpGet } from '../../lib/http.ts';
export class Renderer { export class Renderer {
constructor() { constructor() {
@ -44,11 +45,19 @@ export class Renderer {
return true; return true;
} }
async topicToggleDone() { async loadProgress() {
const { response, error } = await getUserResourceProgressApi({ 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, resourceId: this.resourceId,
resourceType: this.resourceType, resourceType: this.resourceType,
}); }
);
if (!response) { if (!response) {
console.error(error); console.error(error);
@ -75,8 +84,6 @@ export class Renderer {
return null; return null;
} }
console.log(this.resourceType, this.resourceId);
this.containerEl.innerHTML = this.loaderHTML; this.containerEl.innerHTML = this.loaderHTML;
return Promise.all([ return Promise.all([
fetch(jsonUrl) fetch(jsonUrl)
@ -102,7 +109,8 @@ export class Renderer {
this.containerEl.innerHTML = `<div class="error py-5 text-center text-red-600 mx-auto">${message}</div>`; this.containerEl.innerHTML = `<div class="error py-5 text-center text-red-600 mx-auto">${message}</div>`;
}), }),
this.topicToggleDone(),
this.loadProgress(),
]); ]);
} }

@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'preact/hooks'; import { useCallback, useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import Spinner from '../Spinner';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet, httpPost } from '../../lib/http'; import { httpGet, httpPost } from '../../lib/http';

@ -1,29 +0,0 @@
---
const { ...props } = Astro.props;
export type Props = astroHTML.JSX.HTMLAttributes & {};
---
<div role='status'>
<svg
aria-hidden='true'
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
class:list={[`animate-spin h-5 w-5`, props.class]}
{...props}
>
<circle
class='stroke-[4px] opacity-25'
cx='12'
cy='12'
r='10'
stroke='currentColor'></circle>
<path
class='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z'
></path>
</svg>
<span class='sr-only'>Loading</span>
</div>

@ -1,26 +0,0 @@
export default function Spinner({ className }: { className?: string }) {
return (
<div role="status">
<svg
className={`h-5 w-5 animate-spin ${className}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="stroke-[4px] opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span class="sr-only">Loading</span>
</div>
);
}

@ -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<boolean>();
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
const isGuest = useMemo(() => !isLoggedIn(), []);
const topicRef = useRef<HTMLDivElement>(null);
// Details of the currently loaded topic
const [topicId, setTopicId] = useState('');
const [resourceId, setResourceId] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('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<string>(
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 (
<div>
<div
ref={topicRef}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex w-full justify-center">
<img
src={SpinnerIcon}
alt="Loading"
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12"
/>
</div>
)}
{!isLoading && !error && (
<>
{/* Actions for the topic */}
<div className="mb-2">
{isGuest && (
<button
data-popup="login-popup"
className="inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700"
onClick={() => setIsActive(false)}
>
<img alt="Check" src={CheckIcon} />
<span className="ml-2">Mark as Done</span>
</button>
)}
{!isGuest && (
<>
{isUpdatingProgress && (
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
<img
alt="Check"
class="h-4 w-4 animate-spin"
src={SpinnerIcon}
/>
<span className="ml-2">Updating Status..</span>
</button>
)}
{!isUpdatingProgress && !isDone && (
<button
className="inline-flex items-center rounded-md border border-green-600 bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700"
onClick={() => toggleResourceProgress(true)}
>
<img alt="Check" class="h-4 w-4" src={CheckIcon} />
<span className="ml-2">Mark as Done</span>
</button>
)}
{!isUpdatingProgress && isDone && (
<button
className="inline-flex items-center rounded-md border border-red-600 bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700"
onClick={() => toggleResourceProgress(false)}
>
<img alt="Check" class="h-4" src={ResetIcon} />
<span className="ml-2">Mark as Pending</span>
</button>
)}
</>
)}
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => setIsActive(false)}
>
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
</button>
</div>
{/* Topic Content */}
<div
id="topic-content"
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5"
dangerouslySetInnerHTML={{ __html: topicHtml }}
></div>
</>
)}
</div>
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
</div>
);
}

@ -1,4 +1,3 @@
import { toggleMarkResourceDoneApi } from '../../lib/progress-api.ts';
export class Topic { export class Topic {
constructor() { constructor() {
this.overlayId = 'topic-overlay'; this.overlayId = 'topic-overlay';
@ -30,7 +29,6 @@ export class Topic {
this.markAsDone = this.markAsDone.bind(this); this.markAsDone = this.markAsDone.bind(this);
this.markAsPending = this.markAsPending.bind(this); this.markAsPending = this.markAsPending.bind(this);
this.querySvgElementsByTopicId = this.querySvgElementsByTopicId.bind(this); this.querySvgElementsByTopicId = this.querySvgElementsByTopicId.bind(this);
this.rightClickListener = this.rightClickListener.bind(this);
this.isTopicDone = this.isTopicDone.bind(this); this.isTopicDone = this.isTopicDone.bind(this);
this.init = this.init.bind(this); this.init = this.init.bind(this);
@ -64,33 +62,6 @@ export class Topic {
return document.getElementById(this.overlayId); 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) { resetDOM(hideOverlay = false) {
if (hideOverlay) { if (hideOverlay) {
this.overlayEl.classList.add('hidden'); this.overlayEl.classList.add('hidden');
@ -206,7 +177,6 @@ export class Topic {
handleRoadmapTopicClick(e) { handleRoadmapTopicClick(e) {
const { resourceId: roadmapId, topicId } = e.detail; const { resourceId: roadmapId, topicId } = e.detail;
console.log(e.detail);
if (!topicId || !roadmapId) { if (!topicId || !roadmapId) {
console.log('Missing topic or roadmap: ', e.detail); console.log('Missing topic or roadmap: ', e.detail);
return; return;
@ -263,13 +233,7 @@ export class Topic {
async markAsDone(topicId, resourceId, resourceType) { async markAsDone(topicId, resourceId, resourceType) {
const updatedTopicId = topicId.replace(/^\d+-/, ''); const updatedTopicId = topicId.replace(/^\d+-/, '');
console.log('Marking as done: ', updatedTopicId, resourceId, resourceType); const { response, error } = {};
const { response, error } = await toggleMarkResourceDoneApi({
resourceId,
topicId: updatedTopicId,
resourceType,
});
if (response) { if (response) {
this.close(); this.close();
@ -284,11 +248,7 @@ export class Topic {
async markAsPending(topicId, resourceId, resourceType) { async markAsPending(topicId, resourceId, resourceType) {
const updatedTopicId = topicId.replace(/^\d+-/, ''); const updatedTopicId = topicId.replace(/^\d+-/, '');
const { response, error } = await toggleMarkResourceDoneApi({ const { response, error } = {};
resourceId,
topicId: updatedTopicId,
resourceType,
});
if (response) { if (response) {
this.close(); this.close();
@ -356,9 +316,8 @@ export class Topic {
'roadmap.topic.click', 'roadmap.topic.click',
this.handleRoadmapTopicClick this.handleRoadmapTopicClick
); );
window.addEventListener('click', this.handleOverlayClick);
window.addEventListener('contextmenu', this.rightClickListener);
window.addEventListener('click', this.handleOverlayClick);
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'escape') { if (e.key.toLowerCase() === 'escape') {
this.close(); this.close();

@ -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);
};
}, []);
}

@ -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);
};
}, []);
}

@ -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]);
}

@ -1,5 +1,3 @@
<svg viewBox="0 0 14 14" focusable="false" class="h-3 w-3" aria-hidden="true"> <svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor"> <path d="M5.5 9.99933L14 1.49933L12.5 0L5.5 6.99933L1.5 2.99687L0 4.49933L5.5 9.99933Z" fill="white"/>
<polygon points="5.5 11.9993304 14 3.49933039 12.5 2 5.5 8.99933039 1.5 4.9968652 0 6.49933039"></polygon>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 208 B

@ -1,6 +1,4 @@
<svg viewBox="0 0 24 24" focusable="false" class="w-3 h-3" aria-hidden="true"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor"> <path d="M10.3193 4.93528C11.5957 4.63203 12.9306 4.68137 14.1811 5.07803C15.4317 5.47469 16.551 6.20375 17.4193 7.18728C17.639 7.43552 17.9483 7.58631 18.2793 7.60647C18.6102 7.62663 18.9355 7.51451 19.1838 7.29478C19.432 7.07505 19.5828 6.7657 19.603 6.43479C19.6231 6.10389 19.511 5.77852 19.2913 5.53028C18.1237 4.20738 16.6187 3.22659 14.9369 2.69273C13.2552 2.15887 11.46 2.092 9.74327 2.49928C8.00102 2.9367 6.404 3.82349 5.11164 5.07111C3.81927 6.31873 2.87678 7.88352 2.37827 9.60928C2.36179 9.66642 2.32541 9.71578 2.27571 9.74843C2.226 9.78108 2.16625 9.79486 2.10727 9.78728L1.07427 9.65728C0.982658 9.64551 0.889587 9.65981 0.805742 9.69855C0.721897 9.73729 0.650678 9.79889 0.600266 9.87628C0.548506 9.95352 0.519307 10.0437 0.515951 10.1366C0.512595 10.2295 0.535213 10.3215 0.581266 10.4023L3.05727 14.7443C3.09587 14.8118 3.14969 14.8693 3.21444 14.9124C3.27919 14.9554 3.35309 14.9828 3.43027 14.9923C3.45091 14.9938 3.47163 14.9938 3.49227 14.9923C3.55924 14.9923 3.62552 14.9788 3.68719 14.9527C3.74886 14.9266 3.80466 14.8884 3.85127 14.8403L7.32827 11.2473C7.39298 11.1803 7.43773 11.0967 7.45745 11.0057C7.47718 10.9147 7.47111 10.82 7.43993 10.7323C7.40875 10.6445 7.35369 10.5673 7.28096 10.5091C7.20823 10.451 7.12071 10.4144 7.02827 10.4033L5.15027 10.1713C5.11341 10.1661 5.07817 10.1527 5.04714 10.1322C5.01611 10.1116 4.99006 10.0844 4.97089 10.0525C4.95173 10.0205 4.93993 9.98475 4.93636 9.9477C4.93279 9.91065 4.93754 9.87326 4.95027 9.83828C5.37211 8.64203 6.08295 7.56852 7.01961 6.71315C7.95627 5.85779 9.08973 5.24707 10.3193 4.93528Z" fill="white"/>
<path d="M10.319,4.936a7.239,7.239,0,0,1,7.1,2.252,1.25,1.25,0,1,0,1.872-1.657A9.737,9.737,0,0,0,9.743,2.5,10.269,10.269,0,0,0,2.378,9.61a.249.249,0,0,1-.271.178l-1.033-.13A.491.491,0,0,0,.6,9.877a.5.5,0,0,0-.019.526l2.476,4.342a.5.5,0,0,0,.373.248.43.43,0,0,0,.062,0,.5.5,0,0,0,.359-.152l3.477-3.593a.5.5,0,0,0-.3-.844L5.15,10.172a.25.25,0,0,1-.2-.333A7.7,7.7,0,0,1,10.319,4.936Z"></path> <path d="M23.4056 14.1003C23.4568 14.0226 23.4853 13.9323 23.4879 13.8394C23.4905 13.7465 23.4672 13.6547 23.4206 13.5743L20.9206 9.24526C20.8815 9.17807 20.8272 9.12095 20.7621 9.07841C20.697 9.03588 20.6229 9.00912 20.5456 9.00026C20.4685 8.99013 20.3901 8.99854 20.3168 9.02481C20.2436 9.05107 20.1777 9.09442 20.1246 9.15126L16.6686 12.7653C16.6045 12.8323 16.5602 12.9158 16.5408 13.0065C16.5214 13.0972 16.5277 13.1915 16.5588 13.2788C16.5899 13.3662 16.6447 13.4432 16.7171 13.5012C16.7895 13.5592 16.8766 13.5959 16.9686 13.6073L18.8166 13.8283C18.854 13.8327 18.8898 13.8455 18.9215 13.8658C18.9532 13.886 18.9799 13.9132 18.9996 13.9453C19.0192 13.9773 19.0315 14.0133 19.0355 14.0507C19.0394 14.088 19.0351 14.1258 19.0226 14.1613C18.6013 15.3575 17.8906 16.4309 16.9538 17.2859C16.017 18.1408 14.8833 18.7507 13.6536 19.0613C12.3771 19.3639 11.0423 19.3142 9.79178 18.9174C8.54129 18.5206 7.42206 17.7916 6.55361 16.8083C6.44613 16.681 6.31431 16.5765 6.16589 16.5009C6.01746 16.4253 5.85543 16.3802 5.68931 16.3681C5.52318 16.356 5.35632 16.3773 5.19852 16.4306C5.04072 16.4839 4.89517 16.5682 4.77042 16.6786C4.64566 16.7889 4.54422 16.9231 4.47205 17.0732C4.39989 17.2233 4.35845 17.3864 4.35018 17.5527C4.3419 17.7191 4.36696 17.8854 4.42388 18.0419C4.48079 18.1985 4.56842 18.3421 4.68161 18.4643C5.84954 19.7869 7.35483 20.7674 9.03668 21.3011C10.7185 21.8347 12.5138 21.9015 14.2306 21.4943C15.9751 21.0573 17.5742 20.1696 18.8675 18.9199C20.1608 17.6703 21.103 16.1027 21.5996 14.3743C21.6162 14.3173 21.6524 14.2681 21.7019 14.2354C21.7513 14.2026 21.8107 14.1884 21.8696 14.1953L22.9276 14.3223C22.9476 14.3237 22.9676 14.3237 22.9876 14.3223C23.0702 14.3227 23.1516 14.3026 23.2245 14.2638C23.2975 14.2251 23.3597 14.1689 23.4056 14.1003Z" fill="white"/>
<path d="M23.406,14.1a.5.5,0,0,0,.015-.526l-2.5-4.329A.5.5,0,0,0,20.546,9a.489.489,0,0,0-.421.151l-3.456,3.614a.5.5,0,0,0,.3.842l1.848.221a.249.249,0,0,1,.183.117.253.253,0,0,1,.023.216,7.688,7.688,0,0,1-5.369,4.9,7.243,7.243,0,0,1-7.1-2.253,1.25,1.25,0,1,0-1.872,1.656,9.74,9.74,0,0,0,9.549,3.03,10.261,10.261,0,0,0,7.369-7.12.251.251,0,0,1,.27-.179l1.058.127a.422.422,0,0,0,.06,0A.5.5,0,0,0,23.406,14.1Z"></path>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 3.4 KiB

@ -43,7 +43,11 @@ export async function httpCall<
}), }),
}); });
const data = await response.json(); // @ts-ignore
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
const data = doesAcceptHtml ? await response.text() : await response.json();
if (response.ok) { if (response.ok) {
return { return {
response: data as ResponseType, response: data as ResponseType,

@ -1,4 +1,5 @@
import * as jose from 'jose'; import * as jose from 'jose';
import Cookies from 'js-cookie';
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__'; export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
@ -13,3 +14,9 @@ export function decodeToken(token: string): TokenPayload {
return claims as TokenPayload; return claims as TokenPayload;
} }
export function isLoggedIn() {
const token = Cookies.get(TOKEN_COOKIE_NAME);
return !!token;
}

@ -1,34 +0,0 @@
import { httpGet, httpPatch } from './http';
export async function toggleMarkResourceDoneApi({
resourceId,
resourceType,
topicId,
}: {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
topicId: string;
}) {
return await httpPatch<{
status: 'ok';
}>(`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`, {
resourceId,
resourceType,
topicId,
});
}
export async function getUserResourceProgressApi({
resourceId,
resourceType,
}: {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
}) {
return await httpGet<{
done: string[];
}>(`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`, {
resourceId,
resourceType,
});
}

@ -1,5 +1,5 @@
import type { MarkdownFileType } from './file'; import type {MarkdownFileType} from './file';
import type { RoadmapFrontmatter } from './roadmap'; import type {RoadmapFrontmatter} from './roadmap';
// Generates URL from the topic file path e.g. // Generates URL from the topic file path e.g.
// -> /src/data/roadmaps/vue/content/102-ecosystem/102-ssr/101-nuxt-js.md // -> /src/data/roadmaps/vue/content/102-ecosystem/102-ssr/101-nuxt-js.md
@ -47,17 +47,15 @@ function generateBreadcrumbs(
} }
} }
const breadcrumbs = breadcrumbUrls.map((breadCrumbUrl): BreadcrumbItem => { return breadcrumbUrls.map((breadCrumbUrl): BreadcrumbItem => {
const topicFile = topicFiles[breadCrumbUrl]; const topicFile = topicFiles[breadCrumbUrl];
const topicFileContent = topicFile?.file; const topicFileContent = topicFile?.file;
const firstHeading = topicFileContent?.getHeadings()?.[0]; const firstHeading = topicFileContent?.getHeadings()?.[0];
return { title: firstHeading?.text, url: breadCrumbUrl }; return {title: firstHeading?.text, url: breadCrumbUrl};
}); });
return breadcrumbs;
} }
export type BreadcrumbItem = { export type BreadcrumbItem = {
@ -123,7 +121,7 @@ export async function getRoadmapTopicFiles(): Promise<
const roadmapUrl = `/${roadmapId}`; const roadmapUrl = `/${roadmapId}`;
// Breadcrumbs for the file // Breadcrumbs for the file
const breadcrumbs: BreadcrumbItem[] = [ mapping[topicUrl].breadcrumbs = [
{ {
title: 'Roadmaps', title: 'Roadmaps',
url: '/roadmaps', url: '/roadmaps',
@ -138,8 +136,6 @@ export async function getRoadmapTopicFiles(): Promise<
}, },
...generateBreadcrumbs(topicUrl, mapping), ...generateBreadcrumbs(topicUrl, mapping),
]; ];
mapping[topicUrl].breadcrumbs = breadcrumbs;
}); });
return mapping; return mapping;

@ -0,0 +1,112 @@
import { httpGet, httpPatch } from './http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from './jwt';
export type ResourceType = 'roadmap' | 'best-practice';
type TopicMeta = {
topicId: string;
resourceType: ResourceType;
resourceId: string;
};
export async function isTopicDone(topic: TopicMeta): Promise<boolean> {
const { topicId, resourceType, resourceId } = topic;
const doneItems = await getUserResourceProgress(resourceType, resourceId);
if (!doneItems) {
return false;
}
return doneItems.includes(topicId);
}
export async function toggleMarkTopicDone(
topic: TopicMeta,
isDone: boolean
): Promise<void> {
const { topicId, resourceType, resourceId } = topic;
const { response, error } = await httpPatch<{ done: string[] }>(
`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`,
{
topicId,
resourceType,
resourceId,
isDone,
}
);
if (error || !response?.done) {
throw new Error(error?.message || 'Something went wrong');
}
setUserResourceProgress(resourceType, resourceId, response.done);
}
export async function getUserResourceProgress(
resourceType: 'roadmap' | 'best-practice',
resourceId: string
): Promise<string[]> {
const progressKey = `${resourceType}-${resourceId}-progress`;
const rawProgress = localStorage.getItem(progressKey);
const progress = JSON.parse(rawProgress || 'null');
const progressTimestamp = progress?.timestamp;
const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10);
const isProgressExpired = diff > 10 * 60 * 1000; // 10 minutes
console.log(progressKey);
if (!progress || isProgressExpired) {
return loadFreshProgress(resourceType, resourceId);
}
return progress.done;
}
async function loadFreshProgress(
resourceType: ResourceType,
resourceId: string
) {
const { response, error } = await httpGet<{ done: string[] }>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`,
{
resourceType,
resourceId,
}
);
if (error) {
if (error.status === 401) {
Cookies.remove(TOKEN_COOKIE_NAME);
window.location.reload();
return [];
}
console.error(error);
return [];
}
if (!response?.done) {
return [];
}
setUserResourceProgress(resourceType, resourceId, response.done);
return response.done;
}
export function setUserResourceProgress(
resourceType: 'roadmap' | 'best-practice',
resourceId: string,
done: string[]
): void {
localStorage.setItem(
`${resourceType}-${resourceId}-progress`,
JSON.stringify({
done,
timestamp: new Date().getTime(),
})
);
}

@ -1,15 +1,17 @@
--- ---
import CaptchaScripts from '../../components/Captcha/CaptchaScripts.astro';
import FAQs from '../../components/FAQs/FAQs.astro'; import FAQs from '../../components/FAQs/FAQs.astro';
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro'; import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
import MarkdownFile from '../../components/MarkdownFile.astro'; import MarkdownFile from '../../components/MarkdownFile.astro';
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro'; import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
import RoadmapHeader from '../../components/RoadmapHeader.astro'; import RoadmapHeader from '../../components/RoadmapHeader.astro';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro'; import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import TopicOverlay from '../../components/TopicOverlay/TopicOverlay.astro';
import UpcomingForm from '../../components/UpcomingForm.astro'; import UpcomingForm from '../../components/UpcomingForm.astro';
import BaseLayout from '../../layouts/BaseLayout.astro'; import BaseLayout from '../../layouts/BaseLayout.astro';
import { generateArticleSchema, generateFAQSchema } from '../../lib/jsonld-schema'; import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import {
generateArticleSchema,
generateFAQSchema,
} from '../../lib/jsonld-schema';
import { getRoadmapIds, RoadmapFrontmatter } from '../../lib/roadmap'; import { getRoadmapIds, RoadmapFrontmatter } from '../../lib/roadmap';
export async function getStaticPaths() { export async function getStaticPaths() {
@ -25,8 +27,12 @@ interface Params extends Record<string, string | undefined> {
} }
const { roadmapId } = Astro.params as Params; const { roadmapId } = Astro.params as Params;
const roadmapFile = await import(`../../data/roadmaps/${roadmapId}/${roadmapId}.md`); const roadmapFile = await import(
const { faqs: roadmapFAQs = [] } = await import(`../../data/roadmaps/${roadmapId}/faqs.astro`); `../../data/roadmaps/${roadmapId}/${roadmapId}.md`
);
const { faqs: roadmapFAQs = [] } = await import(
`../../data/roadmaps/${roadmapId}/faqs.astro`
);
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter; const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
let jsonLdSchema = []; let jsonLdSchema = [];
@ -62,7 +68,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
jsonLd={jsonLdSchema} jsonLd={jsonLdSchema}
> >
<!-- Preload the font being used in the renderer --> <!-- Preload the font being used in the renderer -->
<link rel='preload' href='/fonts/balsamiq.woff2' as='font' type='font/woff2' crossorigin slot='after-header' /> <link
rel='preload'
href='/fonts/balsamiq.woff2'
as='font'
type='font/woff2'
crossorigin
slot='after-header'
/>
<RoadmapHeader <RoadmapHeader
title={roadmapData.title} title={roadmapData.title}
@ -77,9 +90,12 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
<div class='bg-gray-50 pt-4 sm:pt-12'> <div class='bg-gray-50 pt-4 sm:pt-12'>
{ {
!roadmapData.isUpcoming && roadmapData.jsonUrl && ( !roadmapData.isUpcoming && roadmapData.jsonUrl && (
<div class='max-w-[1000px] container relative'> <div class='container relative max-w-[1000px]'>
<ShareIcons description={roadmapData.briefDescription} pageUrl={`https://roadmap.sh/${roadmapId}`} /> <ShareIcons
<TopicOverlay contentContributionLink={contentContributionLink} /> description={roadmapData.briefDescription}
pageUrl={`https://roadmap.sh/${roadmapId}`}
/>
<TopicDetail client:load />
<FrameRenderer <FrameRenderer
resourceType={'roadmap'} resourceType={'roadmap'}
@ -93,7 +109,7 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
{ {
!roadmapData.isUpcoming && !roadmapData.jsonUrl && ( !roadmapData.isUpcoming && !roadmapData.jsonUrl && (
<div class='mt-0 sm:-mt-6 pb-14'> <div class='mt-0 pb-14 sm:-mt-6'>
<MarkdownFile> <MarkdownFile>
<roadmapFile.Content /> <roadmapFile.Content />
</MarkdownFile> </MarkdownFile>

@ -1,13 +1,15 @@
--- ---
import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
import BestPracticeHeader from '../../../components/BestPracticeHeader.astro'; import BestPracticeHeader from '../../../components/BestPracticeHeader.astro';
import CaptchaScripts from '../../../components/Captcha/CaptchaScripts.astro';
import FrameRenderer from '../../../components/FrameRenderer/FrameRenderer.astro'; import FrameRenderer from '../../../components/FrameRenderer/FrameRenderer.astro';
import MarkdownFile from '../../../components/MarkdownFile.astro'; import MarkdownFile from '../../../components/MarkdownFile.astro';
import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro'; import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro';
import TopicOverlay from '../../../components/TopicOverlay/TopicOverlay.astro';
import UpcomingForm from '../../../components/UpcomingForm.astro'; import UpcomingForm from '../../../components/UpcomingForm.astro';
import BaseLayout from '../../../layouts/BaseLayout.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'; import { generateArticleSchema } from '../../../lib/jsonld-schema';
export async function getStaticPaths() { export async function getStaticPaths() {
@ -23,8 +25,11 @@ interface Params extends Record<string, string | undefined> {
} }
const { bestPracticeId } = Astro.params as Params; const { bestPracticeId } = Astro.params as Params;
const bestPracticeFile = await import(`../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`); const bestPracticeFile = await import(
const bestPracticeData = bestPracticeFile.frontmatter as BestPracticeFrontmatter; `../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`
);
const bestPracticeData =
bestPracticeFile.frontmatter as BestPracticeFrontmatter;
let jsonLdSchema = []; let jsonLdSchema = [];
@ -55,7 +60,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
jsonLd={jsonLdSchema} jsonLd={jsonLdSchema}
> >
<!-- Preload the font being used in the renderer --> <!-- Preload the font being used in the renderer -->
<link rel='preload' href='/fonts/balsamiq.woff2' as='font' type='font/woff2' crossorigin slot='after-header' /> <link
rel='preload'
href='/fonts/balsamiq.woff2'
as='font'
type='font/woff2'
crossorigin
slot='after-header'
/>
<BestPracticeHeader <BestPracticeHeader
title={bestPracticeData.title} title={bestPracticeData.title}
@ -67,12 +79,12 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
<div class='bg-gray-50 py-4 sm:py-12'> <div class='bg-gray-50 py-4 sm:py-12'>
{ {
!bestPracticeData.isUpcoming && bestPracticeData.jsonUrl && ( !bestPracticeData.isUpcoming && bestPracticeData.jsonUrl && (
<div class='max-w-[1000px] container relative'> <div class='container relative max-w-[1000px]'>
<ShareIcons <ShareIcons
description={bestPracticeData.briefDescription} description={bestPracticeData.briefDescription}
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`} pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
/> />
<TopicOverlay contentContributionLink={contentContributionLink} /> <TopicDetail client:load />
<FrameRenderer <FrameRenderer
resourceType={'best-practice'} resourceType={'best-practice'}

Loading…
Cancel
Save