diff --git a/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx b/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx index d6ea54058..e0af4c770 100644 --- a/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx +++ b/src/components/CustomRoadmap/FlowRoadmapRenderer.tsx @@ -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'); diff --git a/src/components/FrameRenderer/FrameRenderer.astro b/src/components/FrameRenderer/FrameRenderer.astro index b96c8383f..620143da5 100644 --- a/src/components/FrameRenderer/FrameRenderer.astro +++ b/src/components/FrameRenderer/FrameRenderer.astro @@ -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; + + diff --git a/src/components/FrameRenderer/ProgressNudge.tsx b/src/components/FrameRenderer/ProgressNudge.tsx new file mode 100644 index 000000000..4c9183616 --- /dev/null +++ b/src/components/FrameRenderer/ProgressNudge.tsx @@ -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 ( + + ); +} diff --git a/src/components/FrameRenderer/renderer.ts b/src/components/FrameRenderer/renderer.ts index 1c364b3c5..415c8ffd6 100644 --- a/src/components/FrameRenderer/renderer.ts +++ b/src/components/FrameRenderer/renderer.ts @@ -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', diff --git a/src/lib/resource-progress.ts b/src/lib/resource-progress.ts index ae7eeb918..9a9fc9e23 100644 --- a/src/lib/resource-progress.ts +++ b/src/lib/resource-progress.ts @@ -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 { } export async function getTopicStatus( - topic: TopicMeta + topic: TopicMeta, ): Promise { 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'); diff --git a/src/stores/roadmap.ts b/src/stores/roadmap.ts index 0cfaddead..ca22c9cd5 100644 --- a/src/stores/roadmap.ts +++ b/src/stores/roadmap.ts @@ -4,9 +4,14 @@ import type { GetRoadmapResponse } from '../components/CustomRoadmap/CustomRoadm export const currentRoadmap = atom(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(); diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 2e78d96a0..49f032d0d 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -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,