mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-29 12:18:23 +01:00
Add progress nudge on roadmap
This commit is contained in:
parent
7e702ee385
commit
82dbca95fb
@ -13,6 +13,7 @@ import type { Node } from 'reactflow';
|
||||
import { useCallback, type MouseEvent, useMemo, useState, useRef } from 'react';
|
||||
import { EmptyRoadmap } from './EmptyRoadmap';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { totalRoadmapNodes } from '../../stores/roadmap.ts';
|
||||
|
||||
type FlowRoadmapRendererProps = {
|
||||
roadmap: RoadmapDocument;
|
||||
@ -138,6 +139,12 @@ export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
|
||||
)}
|
||||
onRendered={() => {
|
||||
renderResourceProgress('roadmap', roadmapId).then(() => {
|
||||
totalRoadmapNodes.set(
|
||||
roadmap?.nodes?.filter((node) => {
|
||||
return ['topic', 'subtopic'].includes(node.type);
|
||||
}).length || 0,
|
||||
);
|
||||
|
||||
if (roadmap?.nodes?.length === 0) {
|
||||
setHideRenderer(true);
|
||||
editorWrapperRef?.current?.classList.add('hidden');
|
||||
|
@ -1,6 +1,7 @@
|
||||
---
|
||||
import Loader from '../Loader.astro';
|
||||
import './FrameRenderer.css';
|
||||
import { ProgressNudge } from "./ProgressNudge";
|
||||
|
||||
export interface Props {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@ -27,4 +28,6 @@ const { resourceId, resourceType, dimensions = null } = Astro.props;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProgressNudge resourceId={resourceId} resourceType={resourceType} client:only="react" />
|
||||
|
||||
<script src='./renderer.ts'></script>
|
||||
|
83
src/components/FrameRenderer/ProgressNudge.tsx
Normal file
83
src/components/FrameRenderer/ProgressNudge.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { getUser } from '../../lib/jwt.ts';
|
||||
import { roadmapProgress, totalRoadmapNodes } from '../../stores/roadmap.ts';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import {HelpCircle} from "lucide-react";
|
||||
|
||||
type ProgressNudgeProps = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export function ProgressNudge(props: ProgressNudgeProps) {
|
||||
const { resourceId, resourceType } = props;
|
||||
|
||||
const $totalRoadmapNodes = useStore(totalRoadmapNodes);
|
||||
const $roadmapProgress = useStore(roadmapProgress);
|
||||
|
||||
const done = $roadmapProgress?.done?.length || 0;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { id: userId } = getUser() || {};
|
||||
|
||||
const hasProgress = done > 0;
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
if (!$totalRoadmapNodes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed hidden sm:block -bottom-full left-1/2 z-30 -translate-x-1/2 transform overflow-hidden rounded-full bg-stone-900 px-4 py-2 text-center text-white shadow-2xl transition-all ',
|
||||
{
|
||||
'bottom-5 opacity-100': !isLoading,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('block', {
|
||||
hidden: hasProgress,
|
||||
})}
|
||||
>
|
||||
<span className="mr-2 text-sm font-semibold uppercase text-yellow-400">
|
||||
Tip
|
||||
</span>
|
||||
<span className="text-sm text-gray-200">
|
||||
Right-click on a topic to mark it as done.{' '}
|
||||
<button
|
||||
data-popup="progress-help"
|
||||
className="cursor-pointer font-semibold text-yellow-500 underline"
|
||||
>
|
||||
Learn more.
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn('relative z-20 block text-sm', {
|
||||
hidden: !hasProgress,
|
||||
})}
|
||||
>
|
||||
<span className="relative -top-[0.45px] mr-2 text-xs font-medium uppercase text-yellow-400">
|
||||
Progress
|
||||
</span>
|
||||
<span>{done}</span> of <span>{$totalRoadmapNodes}</span> Done
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="absolute bottom-0 left-0 top-0 z-10 bg-stone-700"
|
||||
style={{
|
||||
width: `${(done / $totalRoadmapNodes) * 100}%`,
|
||||
}}
|
||||
></span>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -218,6 +218,11 @@ export class Renderer {
|
||||
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
if (normalizedGroupId.startsWith('ext_link:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending',
|
||||
|
@ -3,6 +3,7 @@ import { httpGet, httpPost } from './http';
|
||||
import { TOKEN_COOKIE_NAME, getUser } from './jwt';
|
||||
// @ts-ignore
|
||||
import Element = astroHTML.JSX.Element;
|
||||
import { roadmapProgress, totalRoadmapNodes } from '../stores/roadmap.ts';
|
||||
|
||||
export type ResourceType = 'roadmap' | 'best-practice';
|
||||
export type ResourceProgressType =
|
||||
@ -27,7 +28,7 @@ export async function isTopicDone(topic: TopicMeta): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function getTopicStatus(
|
||||
topic: TopicMeta
|
||||
topic: TopicMeta,
|
||||
): Promise<ResourceProgressType> {
|
||||
const { topicId, resourceType, resourceId } = topic;
|
||||
const progressResult = await getResourceProgress(resourceType, resourceId);
|
||||
@ -49,7 +50,7 @@ export async function getTopicStatus(
|
||||
|
||||
export async function updateResourceProgress(
|
||||
topic: TopicMeta,
|
||||
progressType: ResourceProgressType
|
||||
progressType: ResourceProgressType,
|
||||
) {
|
||||
const { topicId, resourceType, resourceId } = topic;
|
||||
|
||||
@ -74,7 +75,7 @@ export async function updateResourceProgress(
|
||||
resourceId,
|
||||
response.done,
|
||||
response.learning,
|
||||
response.skipped
|
||||
response.skipped,
|
||||
);
|
||||
|
||||
return response;
|
||||
@ -82,7 +83,7 @@ export async function updateResourceProgress(
|
||||
|
||||
export async function getResourceProgress(
|
||||
resourceType: 'roadmap' | 'best-practice',
|
||||
resourceId: string
|
||||
resourceId: string,
|
||||
): Promise<{ done: string[]; learning: string[]; skipped: string[] }> {
|
||||
// No need to load progress if user is not logged in
|
||||
if (!Cookies.get(TOKEN_COOKIE_NAME)) {
|
||||
@ -109,6 +110,14 @@ export async function getResourceProgress(
|
||||
|
||||
if (!progress || isProgressExpired) {
|
||||
return loadFreshProgress(resourceType, resourceId);
|
||||
} else {
|
||||
setResourceProgress(
|
||||
resourceType,
|
||||
resourceId,
|
||||
progress?.done || [],
|
||||
progress?.learning || [],
|
||||
progress?.skipped || [],
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatch event to update favorite status in the MarkFavorite component
|
||||
@ -119,7 +128,7 @@ export async function getResourceProgress(
|
||||
resourceId,
|
||||
isFavorite,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return progress;
|
||||
@ -127,7 +136,7 @@ export async function getResourceProgress(
|
||||
|
||||
async function loadFreshProgress(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string
|
||||
resourceId: string,
|
||||
) {
|
||||
const { response, error } = await httpGet<{
|
||||
done: string[];
|
||||
@ -153,7 +162,7 @@ async function loadFreshProgress(
|
||||
resourceId,
|
||||
response?.done || [],
|
||||
response?.learning || [],
|
||||
response?.skipped || []
|
||||
response?.skipped || [],
|
||||
);
|
||||
|
||||
// Dispatch event to update favorite status in the MarkFavorite component
|
||||
@ -164,7 +173,7 @@ async function loadFreshProgress(
|
||||
resourceId,
|
||||
isFavorite: response.isFavorite,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return response;
|
||||
@ -175,8 +184,14 @@ export function setResourceProgress(
|
||||
resourceId: string,
|
||||
done: string[],
|
||||
learning: string[],
|
||||
skipped: string[]
|
||||
skipped: string[],
|
||||
): void {
|
||||
roadmapProgress.set({
|
||||
done,
|
||||
learning,
|
||||
skipped,
|
||||
});
|
||||
|
||||
const userId = getUser()?.id;
|
||||
localStorage.setItem(
|
||||
`${resourceType}-${resourceId}-${userId}-progress`,
|
||||
@ -185,13 +200,13 @@ export function setResourceProgress(
|
||||
learning,
|
||||
skipped,
|
||||
timestamp: new Date().getTime(),
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function topicSelectorAll(
|
||||
topicId: string,
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document,
|
||||
): Element[] {
|
||||
const matchingElements: Element[] = [];
|
||||
|
||||
@ -215,7 +230,7 @@ export function topicSelectorAll(
|
||||
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
|
||||
`[data-id="${topicId}"]`, // Matching custom roadmap nodes
|
||||
],
|
||||
parentElement
|
||||
parentElement,
|
||||
).forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
@ -225,7 +240,7 @@ export function topicSelectorAll(
|
||||
|
||||
export function renderTopicProgress(
|
||||
topicId: string,
|
||||
topicProgress: ResourceProgressType
|
||||
topicProgress: ResourceProgressType,
|
||||
) {
|
||||
const isLearning = topicProgress === 'learning';
|
||||
const isSkipped = topicProgress === 'skipped';
|
||||
@ -268,7 +283,7 @@ export function clearResourceProgress() {
|
||||
|
||||
export async function renderResourceProgress(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string
|
||||
resourceId: string,
|
||||
) {
|
||||
const {
|
||||
done = [],
|
||||
@ -293,7 +308,7 @@ export async function renderResourceProgress(
|
||||
|
||||
function getMatchingElements(
|
||||
quries: string[],
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document,
|
||||
): Element[] {
|
||||
const matchingElements: Element[] = [];
|
||||
quries.forEach((query) => {
|
||||
@ -306,7 +321,7 @@ function getMatchingElements(
|
||||
|
||||
export function refreshProgressCounters() {
|
||||
const progressNumsContainers = document.querySelectorAll(
|
||||
'[data-progress-nums-container]'
|
||||
'[data-progress-nums-container]',
|
||||
);
|
||||
const progressNums = document.querySelectorAll('[data-progress-nums]');
|
||||
if (progressNumsContainers.length === 0 || progressNums.length === 0) {
|
||||
@ -322,27 +337,27 @@ export function refreshProgressCounters() {
|
||||
]).length;
|
||||
|
||||
const externalLinks = document.querySelectorAll(
|
||||
'[data-group-id^="ext_link:"]'
|
||||
'[data-group-id^="ext_link:"]',
|
||||
).length;
|
||||
const roadmapSwitchers = document.querySelectorAll(
|
||||
'[data-group-id^="json:"]'
|
||||
'[data-group-id^="json:"]',
|
||||
).length;
|
||||
const checkBoxes = document.querySelectorAll(
|
||||
'[data-group-id^="check:"]'
|
||||
'[data-group-id^="check:"]',
|
||||
).length;
|
||||
|
||||
const totalCheckBoxesDone = document.querySelectorAll(
|
||||
'[data-group-id^="check:"].done'
|
||||
'[data-group-id^="check:"].done',
|
||||
).length;
|
||||
const totalCheckBoxesLearning = document.querySelectorAll(
|
||||
'[data-group-id^="check:"].learning'
|
||||
'[data-group-id^="check:"].learning',
|
||||
).length;
|
||||
const totalCheckBoxesSkipped = document.querySelectorAll(
|
||||
'[data-group-id^="check:"].skipped'
|
||||
'[data-group-id^="check:"].skipped',
|
||||
).length;
|
||||
|
||||
const totalRemoved = document.querySelectorAll(
|
||||
'.clickable-group.removed'
|
||||
'.clickable-group.removed',
|
||||
).length;
|
||||
const totalItems =
|
||||
totalClickable -
|
||||
@ -351,6 +366,8 @@ export function refreshProgressCounters() {
|
||||
checkBoxes -
|
||||
totalRemoved;
|
||||
|
||||
totalRoadmapNodes.set(totalItems);
|
||||
|
||||
const totalDone =
|
||||
getMatchingElements([
|
||||
'.clickable-group.done:not([data-group-id^="ext_link:"])',
|
||||
@ -373,47 +390,47 @@ export function refreshProgressCounters() {
|
||||
const doneCountEls = document.querySelectorAll('[data-progress-done]');
|
||||
if (doneCountEls.length > 0) {
|
||||
doneCountEls.forEach(
|
||||
(doneCountEl) => (doneCountEl.innerHTML = `${totalDone}`)
|
||||
(doneCountEl) => (doneCountEl.innerHTML = `${totalDone}`),
|
||||
);
|
||||
}
|
||||
|
||||
const learningCountEls = document.querySelectorAll(
|
||||
'[data-progress-learning]'
|
||||
'[data-progress-learning]',
|
||||
);
|
||||
if (learningCountEls.length > 0) {
|
||||
learningCountEls.forEach(
|
||||
(learningCountEl) => (learningCountEl.innerHTML = `${totalLearning}`)
|
||||
(learningCountEl) => (learningCountEl.innerHTML = `${totalLearning}`),
|
||||
);
|
||||
}
|
||||
|
||||
const skippedCountEls = document.querySelectorAll('[data-progress-skipped]');
|
||||
if (skippedCountEls.length > 0) {
|
||||
skippedCountEls.forEach(
|
||||
(skippedCountEl) => (skippedCountEl.innerHTML = `${totalSkipped}`)
|
||||
(skippedCountEl) => (skippedCountEl.innerHTML = `${totalSkipped}`),
|
||||
);
|
||||
}
|
||||
|
||||
const totalCountEls = document.querySelectorAll('[data-progress-total]');
|
||||
if (totalCountEls.length > 0) {
|
||||
totalCountEls.forEach(
|
||||
(totalCountEl) => (totalCountEl.innerHTML = `${totalItems}`)
|
||||
(totalCountEl) => (totalCountEl.innerHTML = `${totalItems}`),
|
||||
);
|
||||
}
|
||||
|
||||
const progressPercentage =
|
||||
Math.round(((totalDone + totalSkipped) / totalItems) * 100) || 0;
|
||||
const progressPercentageEls = document.querySelectorAll(
|
||||
'[data-progress-percentage]'
|
||||
'[data-progress-percentage]',
|
||||
);
|
||||
if (progressPercentageEls.length > 0) {
|
||||
progressPercentageEls.forEach(
|
||||
(progressPercentageEl) =>
|
||||
(progressPercentageEl.innerHTML = `${progressPercentage}`)
|
||||
(progressPercentageEl.innerHTML = `${progressPercentage}`),
|
||||
);
|
||||
}
|
||||
|
||||
progressNumsContainers.forEach((progressNumsContainer) =>
|
||||
progressNumsContainer.classList.remove('striped-loader')
|
||||
progressNumsContainer.classList.remove('striped-loader'),
|
||||
);
|
||||
progressNums.forEach((progressNum) => {
|
||||
progressNum.classList.remove('opacity-0');
|
||||
|
@ -4,9 +4,14 @@ import type { GetRoadmapResponse } from '../components/CustomRoadmap/CustomRoadm
|
||||
export const currentRoadmap = atom<GetRoadmapResponse | undefined>(undefined);
|
||||
export const isCurrentRoadmapPersonal = computed(
|
||||
currentRoadmap,
|
||||
(roadmap) => !roadmap?.teamId
|
||||
(roadmap) => !roadmap?.teamId,
|
||||
);
|
||||
export const canManageCurrentRoadmap = computed(
|
||||
currentRoadmap,
|
||||
(roadmap) => roadmap?.canManage
|
||||
(roadmap) => roadmap?.canManage,
|
||||
);
|
||||
|
||||
export const roadmapProgress = atom<
|
||||
{ done: string[]; learning: string[]; skipped: string[] } | undefined
|
||||
>();
|
||||
export const totalRoadmapNodes = atom<number | undefined>();
|
||||
|
@ -1,8 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}',
|
||||
'./editor/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}'
|
||||
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}',
|
||||
'./editor/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}',
|
||||
],
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
|
Loading…
x
Reference in New Issue
Block a user