Refactor to fix editor scaling issues (#4618)

* Ignore editor file

* Integrate Readonly Editor

* Remove logs

* Implement minimum height

* Implement Custom Roadmap Modal

* Implement Custom Roadmap progress modal

* Implement Readonly Editor

* Implement utils

* Update `gitignore`

* Fix generate renderer script

* Refactor UI

* Add Empty Roadmap state

* Upgrade dependencies and editor update

* Update deployment workflow

* Update roadmap header

* Update dependencies

* Refactor Readonly editor

* Add Readonly Dummy Editor

* Add editor to gitignore

* Add Assume Unchanged

* Add editor in the tailwind

* Fix tailwind issue

* Fix URL for add friends

* Add share with friends functionality

* Update workflow

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
pull/4622/head
Kamran Ahmed 1 year ago committed by GitHub
parent d46cf26812
commit 3a0e588530
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      .gitignore
  2. 2
      .prettierrc.cjs
  3. 14
      editor/readonly-editor.tsx
  4. 62
      package.json
  5. 2858
      pnpm-lock.yaml
  6. 17
      scripts/generate-renderer.sh
  7. 3
      src/components/AccountSidebar.astro
  8. 4
      src/components/AuthenticationFlow/EmailLoginForm.tsx
  9. 2
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapButton.tsx
  10. 14
      src/components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx
  11. 10
      src/components/CustomRoadmap/CustomRoadmap.tsx
  12. 12
      src/components/CustomRoadmap/EmptyRoadmap.tsx
  13. 158
      src/components/CustomRoadmap/FlowRoadmapRenderer.tsx
  14. 2
      src/components/CustomRoadmap/ResourceProgressStats.tsx
  15. 2
      src/components/CustomRoadmap/RoadmapHeader.tsx
  16. 53
      src/components/CustomRoadmap/RoadmapRenderer.css
  17. 177
      src/components/CustomRoadmap/RoadmapRenderer.tsx
  18. 30
      src/components/Friends/FriendsPage.tsx
  19. 10
      src/components/Questions/QuestionFinished.tsx
  20. 24
      src/components/Questions/QuestionsList.tsx
  21. 6
      src/components/Questions/QuestionsProgress.tsx
  22. 6
      src/components/RoadmapTitleQuestion.tsx
  23. 73
      src/components/ShareOptions/ShareFriendList.tsx
  24. 2
      src/components/TeamMarketing/TeamHeroBanner.tsx
  25. 6
      src/components/TeamMarketing/TeamPricing.tsx
  26. 294
      src/components/TeamProgress/MemberCustomProgressModal.tsx
  27. 206
      src/components/TeamProgress/MemberProgressModal.tsx
  28. 148
      src/components/TeamProgress/MemberProgressModalHeader.tsx
  29. 8
      src/components/TeamProgress/TeamProgressPage.tsx
  30. 16
      src/components/TopicDetail/TopicDetail.tsx
  31. 37
      src/components/UserProgress/ProgressLoadingError.tsx
  32. 218
      src/components/UserProgress/UserCustomProgressModal.tsx
  33. 124
      src/components/UserProgress/UserProgressModal.tsx
  34. 79
      src/components/UserProgress/UserProgressModalHeader.tsx
  35. 1
      src/env.d.ts
  36. 18
      src/lib/resource-progress.ts
  37. 5
      tailwind.config.cjs

5
.gitignore vendored

@ -29,6 +29,5 @@ pnpm-debug.log*
tests-examples tests-examples
*.csv *.csv
/renderer/* /editor/*
!/renderer/index.tsx !/editor/readonly-editor.tsx
!/renderer/renderer.ts

@ -13,6 +13,6 @@ module.exports = {
], ],
plugins: [ plugins: [
require.resolve('prettier-plugin-astro'), require.resolve('prettier-plugin-astro'),
require('prettier-plugin-tailwindcss'), 'prettier-plugin-tailwindcss',
], ],
}; };

@ -0,0 +1,14 @@
export function ReadonlyEditor(props: any) {
return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
<p className="mb-4">
Renderer is a private component. If you are a collaborator and have
access to it. Run the following command:
</p>
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
npm run generate-renderer
</code>
</div>
);
}

@ -16,53 +16,55 @@
"roadmap-links": "node scripts/roadmap-links.cjs", "roadmap-links": "node scripts/roadmap-links.cjs",
"roadmap-dirs": "node scripts/roadmap-dirs.cjs", "roadmap-dirs": "node scripts/roadmap-dirs.cjs",
"roadmap-content": "node scripts/roadmap-content.cjs", "roadmap-content": "node scripts/roadmap-content.cjs",
"generate-renderer": "sh scripts/generate-renderer.sh",
"best-practice-dirs": "node scripts/best-practice-dirs.cjs", "best-practice-dirs": "node scripts/best-practice-dirs.cjs",
"best-practice-content": "node scripts/best-practice-content.cjs", "best-practice-content": "node scripts/best-practice-content.cjs",
"generate-renderer": "sh scripts/generate-renderer.sh",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@astrojs/react": "^3.0.0", "@astrojs/react": "^3.0.3",
"@astrojs/sitemap": "^1.3.3", "@astrojs/sitemap": "^3.0.2",
"@astrojs/tailwind": "^5.0.0", "@astrojs/tailwind": "^5.0.2",
"@fingerprintjs/fingerprintjs": "^3.4.1", "@fingerprintjs/fingerprintjs": "^4.1.0",
"@nanostores/react": "^0.7.1", "@nanostores/react": "^0.7.1",
"@types/react": "^18.0.21", "@types/react": "^18.2.29",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.2.14",
"astro": "^3.0.5", "astro": "^3.3.2",
"astro-compress": "^2.0.8", "astro-compress": "^2.0.15",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"dracula-prism": "^2.1.13", "dracula-prism": "^2.1.13",
"jose": "^4.14.4", "jose": "^4.15.4",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.274.0", "lucide-react": "^0.288.0",
"nanoid": "^4.0.2", "nanoid": "^5.0.2",
"nanostores": "^0.9.2", "nanostores": "^0.9.4",
"node-html-parser": "^6.1.5", "node-html-parser": "^6.1.10",
"npm-check-updates": "^16.10.12", "npm-check-updates": "^16.14.6",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react": "^18.0.0", "react": "^18.2.0",
"react-confetti": "^6.1.0", "react-confetti": "^6.1.0",
"react-dom": "^18.0.0", "react-dom": "^18.2.0",
"reactflow": "^11.8.3", "reactflow": "^11.9.4",
"rehype-external-links": "^2.1.0", "@roadmapsh/web-draw": "git+https://github.com/roadmapsh/web-draw.git",
"rehype-external-links": "^3.0.0",
"roadmap-renderer": "^1.0.6", "roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3" "tailwindcss": "^3.3.3",
"zustand": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.35.1", "@playwright/test": "^1.39.0",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.10",
"@types/js-cookie": "^3.0.3", "@types/js-cookie": "^3.0.5",
"@types/prismjs": "^1.26.0", "@types/prismjs": "^1.26.2",
"csv-parser": "^3.0.0", "csv-parser": "^3.0.0",
"gh-pages": "^5.0.0", "gh-pages": "^6.0.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.2",
"openai": "^3.3.0", "openai": "^4.12.4",
"prettier": "^2.8.8", "prettier": "^3.0.3",
"prettier-plugin-astro": "^0.10.0", "prettier-plugin-astro": "^0.12.0",
"prettier-plugin-tailwindcss": "^0.3.0" "prettier-plugin-tailwindcss": "^0.5.6"
} }
} }

File diff suppressed because it is too large Load Diff

@ -1,4 +1,4 @@
#!/usr/bin/env bash -#!/usr/bin/env bash
set -e set -e
@ -8,17 +8,17 @@ if [ ! -d ".temp/web-draw" ]; then
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
fi fi
rm -rf renderer rm -rf editor
mkdir renderer mkdir editor
# copy the files at /src/editor/renderer/* to /renderer # copy the files at /src/editor/* to /editor
# while replacing any existing files # while replacing any existing files
cp -rf .temp/web-draw/src/editor/renderer/* renderer cp -rf .temp/web-draw/src/editor/* editor
# Add @ts-nocheck to the top of each ts and tsx file # Add @ts-nocheck to the top of each ts and tsx file
# so that the typescript compiler doesn't complain # so that the typescript compiler doesn't complain
# about the missing types # about the missing types
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do find editor -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
if [ -f "$file" ]; then if [ -f "$file" ]; then
echo "// @ts-nocheck" > temp echo "// @ts-nocheck" > temp
cat "$file" >> temp cat "$file" >> temp
@ -28,6 +28,5 @@ find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= r
done done
# ignore the worktree changes for the editor directory
# ignore the worktree changes for the renderer directory git update-index --assume-unchanged editor/readonly-editor.tsx
git update-index --skip-worktree renderer/*

@ -167,8 +167,7 @@ const sidebarLinks = [
{sidebarLink.title} {sidebarLink.title}
</span> </span>
{sidebarLink.isNew && {sidebarLink.isNew && !isActive && (
!isActive && (
<span class='relative mr-1 flex items-center'> <span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' /> <span class='relative rounded-full bg-gray-200 p-1 text-xs' />
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' /> <span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />

@ -21,7 +21,7 @@ export function EmailLoginForm() {
{ {
email, email,
password, password,
} },
); );
// Log the user in and reload the page // Log the user in and reload the page
@ -39,7 +39,7 @@ export function EmailLoginForm() {
// @todo use proper types // @todo use proper types
if ((error as any).type === 'user_not_verified') { if ((error as any).type === 'user_not_verified') {
window.location.href = `/verification-pending?email=${encodeURIComponent( window.location.href = `/verification-pending?email=${encodeURIComponent(
email email,
)}`; )}`;
return; return;
} }

@ -38,7 +38,7 @@ export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
<button <button
className={cn( className={cn(
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300', 'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300',
className className,
)} )}
onClick={toggleCreateRoadmapHandler} onClick={toggleCreateRoadmapHandler}
> >

@ -62,7 +62,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
async function handleSubmit( async function handleSubmit(
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>, e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
redirect: boolean = true redirect: boolean = true,
) { ) {
e.preventDefault(); e.preventDefault();
if (isLoading) { if (isLoading) {
@ -85,7 +85,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
}), }),
nodes: [], nodes: [],
edges: [], edges: [],
} },
); );
if (error) { if (error) {
@ -96,9 +96,9 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
toast.success('Roadmap created successfully'); toast.success('Roadmap created successfully');
if (redirect) { if (redirect) {
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${ window.location.href = `${
response?._id import.meta.env.PUBLIC_EDITOR_APP_URL
}`; }/${response?._id}`;
return; return;
} }
@ -186,7 +186,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
type="button" type="button"
className={cn( className={cn(
'block h-9 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100', 'block h-9 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100',
!teamId && 'w-full' !teamId && 'w-full',
)} )}
> >
Cancel Cancel
@ -213,7 +213,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
type="submit" type="submit"
className={cn( className={cn(
'flex h-9 items-center justify-center rounded-md border border-transparent bg-black px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800', 'flex h-9 items-center justify-center rounded-md border border-transparent bg-black px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800',
teamId ? 'hidden sm:flex' : 'w-full' teamId ? 'hidden sm:flex' : 'w-full',
)} )}
> >
{isLoading ? ( {isLoading ? (

@ -7,13 +7,12 @@ import {
httpPost, httpPost,
} from '../../lib/http'; } from '../../lib/http';
import { RoadmapHeader } from './RoadmapHeader'; import { RoadmapHeader } from './RoadmapHeader';
import { RoadmapRenderer } from './RoadmapRenderer';
import { TopicDetail } from '../TopicDetail/TopicDetail'; import { TopicDetail } from '../TopicDetail/TopicDetail';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal'; import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { currentRoadmap } from '../../stores/roadmap'; import { currentRoadmap } from '../../stores/roadmap';
import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { RestrictedPage } from './RestrictedPage'; import { RestrictedPage } from './RestrictedPage';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import { FlowRoadmapRenderer } from './FlowRoadmapRenderer';
export const allowedLinkTypes = [ export const allowedLinkTypes = [
'video', 'video',
@ -121,13 +120,8 @@ export function CustomRoadmap() {
return ( return (
<> <>
<RoadmapHeader /> <RoadmapHeader />
<RoadmapRenderer roadmap={roadmap!} /> <FlowRoadmapRenderer roadmap={roadmap!} />
<TopicDetail canSubmitContribution={false} /> <TopicDetail canSubmitContribution={false} />
<UserProgressModal
resourceId={roadmap?._id!}
resourceType="roadmap"
isCustomResource={true}
/>
</> </>
); );
} }

@ -1,16 +1,18 @@
import {CircleSlash, PenSquare, Shapes} from 'lucide-react'; import { CircleSlash, PenSquare, Shapes } from 'lucide-react';
import { cn } from '../../lib/classname';
type EmptyRoadmapProps = { type EmptyRoadmapProps = {
roadmapId: string; roadmapId: string;
canManage: boolean; canManage: boolean;
className?: string;
}; };
export function EmptyRoadmap(props: EmptyRoadmapProps) { export function EmptyRoadmap(props: EmptyRoadmapProps) {
const { roadmapId, canManage } = props; const { roadmapId, canManage, className } = props;
const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`; const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`;
return ( return (
<div className="flex h-full items-center justify-center"> <div className={cn('flex h-full items-center justify-center', className)}>
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" /> <CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
<h3 className="mt-2">This roadmap is currently empty.</h3> <h3 className="mt-2">This roadmap is currently empty.</h3>
@ -18,9 +20,9 @@ export function EmptyRoadmap(props: EmptyRoadmapProps) {
{canManage && ( {canManage && (
<a <a
href={editUrl} href={editUrl}
className="mt-4 rounded-md bg-gray-500 px-4 py-2 font-medium text-white hover:bg-gray-600 flex items-center" className="mt-4 flex items-center rounded-md bg-gray-500 px-4 py-2 font-medium text-white hover:bg-gray-600"
> >
<Shapes className="inline-block mr-2 h-4 w-4" /> <Shapes className="mr-2 inline-block h-4 w-4" />
Edit Roadmap Edit Roadmap
</a> </a>
)} )}

@ -0,0 +1,158 @@
import { ReadonlyEditor } from '../../../editor/readonly-editor';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { Node } from 'reactflow';
import { useCallback, type MouseEvent, useMemo, useState, useRef } from 'react';
import { EmptyRoadmap } from './EmptyRoadmap';
import { cn } from '../../lib/classname';
type FlowRoadmapRendererProps = {
roadmap: RoadmapDocument;
};
export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
const { roadmap } = props;
const roadmapId = String(roadmap._id!);
const [hideRenderer, setHideRenderer] = useState(false);
const editorWrapperRef = useRef<HTMLDivElement>(null);
const toast = useToast();
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType,
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: roadmapId,
resourceType: 'roadmap',
topicId,
},
newStatus,
)
.then(() => {
renderTopicProgress(topicId, newStatus);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
refreshProgressCounters();
});
return;
}
const handleTopicRightClick = useCallback((e: MouseEvent, node: Node) => {
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
const isCurrentStatusDone = target?.classList.contains('done');
updateTopicStatus(node.id, isCurrentStatusDone ? 'pending' : 'done');
}, []);
const handleTopicShiftClick = useCallback((e: MouseEvent, node: Node) => {
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
const isCurrentStatusLearning = target?.classList.contains('learning');
updateTopicStatus(
node.id,
isCurrentStatusLearning ? 'pending' : 'learning',
);
}, []);
const handleTopicAltClick = useCallback((e: MouseEvent, node: Node) => {
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
const isCurrentStatusSkipped = target?.classList.contains('skipped');
updateTopicStatus(node.id, isCurrentStatusSkipped ? 'pending' : 'skipped');
}, []);
const handleTopicClick = useCallback((e: MouseEvent, node: Node) => {
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: node.id,
resourceId: roadmapId,
resourceType: 'roadmap',
isCustomResource: true,
},
}),
);
}, []);
const handleLinkClick = useCallback((linkId: string, href: string) => {
if (!href) {
return;
}
const isExternalLink = href.startsWith('http');
if (isExternalLink) {
window.open(href, '_blank');
} else {
window.location.href = href;
}
}, []);
return (
<>
{hideRenderer && (
<EmptyRoadmap
roadmapId={roadmapId}
canManage={roadmap.canManage}
className="grow"
/>
)}
<ReadonlyEditor
ref={editorWrapperRef}
roadmap={roadmap}
className={cn(
roadmap?.nodes?.length === 0
? 'grow'
: 'min-h-0 max-md:min-h-[1000px]',
)}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
editorWrapperRef?.current?.classList.add('hidden');
}
});
}}
onTopicClick={handleTopicClick}
onTopicRightClick={handleTopicRightClick}
onTopicShiftClick={handleTopicShiftClick}
onTopicAltClick={handleTopicAltClick}
onButtonNodeClick={handleLinkClick}
onLinkClick={handleLinkClick}
fontFamily="Balsamiq Sans"
fontURL="/fonts/balsamiq.woff2"
/>
</>
);
}

@ -43,7 +43,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
<div <div
data-progress-nums-container="" data-progress-nums-container=""
className={cn( className={cn(
'striped-loader relative hidden items-center justify-between bg-white px-2 py-1.5 sm:flex', 'striped-loader relative z-50 hidden items-center justify-between bg-white px-2 py-1.5 sm:flex',
{ {
'rounded-bl-md rounded-br-md': isSecondaryBanner, 'rounded-bl-md rounded-br-md': isSecondaryBanner,
'rounded-md': !isSecondaryBanner, 'rounded-md': !isSecondaryBanner,

@ -102,7 +102,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
</span> </span>
{team && ( {team && (
<> <>
&nbsp;in&nbsp; &nbsp;from&nbsp;
<span className="font-semibold text-gray-900"> <span className="font-semibold text-gray-900">
{team?.name} {team?.name}
</span> </span>

@ -1,53 +0,0 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg > g > g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

@ -1,177 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Renderer } from '../../../renderer';
import './RoadmapRenderer.css';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { EmptyRoadmap } from './EmptyRoadmap';
type RoadmapRendererProps = {
roadmap: RoadmapDocument;
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
};
export function getNodeDetails(
svgElement: SVGElement
): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup };
}
export const allowedClickableNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
];
export function RoadmapRenderer(props: RoadmapRendererProps) {
const { roadmap } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const roadmapId = roadmap._id!;
const toast = useToast();
const [hideRenderer, setHideRenderer] = useState(false);
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: roadmapId,
resourceType: 'roadmap',
topicId,
},
newStatus
)
.then(() => {
renderTopicProgress(topicId, newStatus);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
refreshProgressCounters();
});
return;
}
const handleSvgClick = useCallback((e: MouseEvent) => {
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning'
);
return;
} else if (e.altKey) {
e.preventDefault();
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: nodeId,
resourceId: roadmap?._id,
resourceType: 'roadmap',
isCustomResource: true,
},
})
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
return;
}
const isCurrentStatusDone = targetGroup?.classList.contains('done');
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
}, []);
useEffect(() => {
if (!roadmapRef?.current) return;
roadmapRef?.current?.addEventListener('click', handleSvgClick);
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
return () => {
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
roadmapRef?.current?.removeEventListener(
'contextmenu',
handleSvgRightClick
);
};
}, []);
return (
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
<div className="container !max-w-[1000px]">
<Renderer
ref={roadmapRef}
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
roadmapRef?.current?.classList.add('hidden');
}
});
}}
/>
{hideRenderer && (
<EmptyRoadmap roadmapId={roadmapId} canManage={roadmap.canManage} />
)}
</div>
</div>
);
}

@ -10,6 +10,7 @@ import { FriendProgressItem } from './FriendProgressItem';
import UserIcon from '../../icons/user.svg'; import UserIcon from '../../icons/user.svg';
import { UserProgressModal } from '../UserProgress/UserProgressModal'; import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { InviteFriendPopup } from './InviteFriendPopup'; import { InviteFriendPopup } from './InviteFriendPopup';
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
type FriendResourceProgress = { type FriendResourceProgress = {
updatedAt: string; updatedAt: string;
@ -107,6 +108,25 @@ export function FriendsPage() {
return <EmptyFriends befriendUrl={befriendUrl} />; return <EmptyFriends befriendUrl={befriendUrl} />;
} }
const progressModal =
showFriendProgress && showFriendProgress?.isCustomResource ? (
<UserCustomProgressModal
userId={showFriendProgress?.friend.userId}
resourceId={showFriendProgress.resourceId}
resourceType="roadmap"
isCustomResource={true}
onClose={() => setShowFriendProgress(undefined)}
/>
) : (
<UserProgressModal
userId={showFriendProgress?.friend.userId}
resourceId={showFriendProgress?.resourceId!}
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress?.isCustomResource}
/>
);
return ( return (
<div> <div>
{showInviteFriendPopup && ( {showInviteFriendPopup && (
@ -116,15 +136,7 @@ export function FriendsPage() {
/> />
)} )}
{showFriendProgress && ( {showFriendProgress && progressModal}
<UserProgressModal
userId={showFriendProgress.friend.userId}
resourceId={showFriendProgress.resourceId}
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress.isCustomResource}
/>
)}
<div className="mb-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center sm:gap-0"> <div className="mb-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

@ -23,7 +23,7 @@ function ProgressStatButton(props: ProgressStatButtonProps) {
<button <button
disabled={isDisabled} disabled={isDisabled}
onClick={onClick} onClick={onClick}
className="group relative text-sm sm:text-base flex flex-1 items-center overflow-hidden rounded-md sm:rounded-xl border border-gray-300 bg-white py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50" className="group relative flex flex-1 items-center overflow-hidden rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50 sm:rounded-xl sm:px-4 sm:py-3 sm:text-base"
> >
{icon} {icon}
<span className="flex flex-grow justify-between"> <span className="flex flex-grow justify-between">
@ -31,7 +31,7 @@ function ProgressStatButton(props: ProgressStatButtonProps) {
<span>{count}</span> <span>{count}</span>
</span> </span>
<span className="absolute top-full left-0 right-0 flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0"> <span className="absolute left-0 right-0 top-full flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
Restart Asking Restart Asking
</span> </span>
</button> </button>
@ -62,7 +62,7 @@ export function QuestionFinished(props: QuestionFinishedProps) {
<span className="inline sm:hidden">questions</span> <span className="inline sm:hidden">questions</span>
</p> </p>
<div className="mt-5 mb-5 flex w-full flex-col gap-1.5 sm:gap-3 px-2 sm:flex-row sm:px-16"> <div className="mb-5 mt-5 flex w-full flex-col gap-1.5 px-2 sm:flex-row sm:gap-3 sm:px-16">
<ProgressStatButton <ProgressStatButton
icon={<ThumbsUp className="mr-1 h-4" />} icon={<ThumbsUp className="mr-1 h-4" />}
label="Knew" label="Knew"
@ -85,10 +85,10 @@ export function QuestionFinished(props: QuestionFinishedProps) {
onClick={() => onReset('skip')} onClick={() => onReset('skip')}
/> />
</div> </div>
<div className="mt-2 mb-4 sm:mb-0 text-sm"> <div className="mb-4 mt-2 text-sm sm:mb-0">
<button <button
onClick={() => onReset('reset')} onClick={() => onReset('reset')}
className="flex items-center gap-0.5 text-red-700 hover:text-black text-sm sm:text-base" className="flex items-center gap-0.5 text-sm text-red-700 hover:text-black sm:text-base"
> >
<RefreshCcw className="mr-1 h-4" /> <RefreshCcw className="mr-1 h-4" />
Restart Asking Restart Asking

@ -46,7 +46,7 @@ export function QuestionsList(props: QuestionsListProps) {
const { response, error } = await httpGet<UserQuestionProgress>( const { response, error } = await httpGet<UserQuestionProgress>(
`${ `${
import.meta.env.PUBLIC_API_URL import.meta.env.PUBLIC_API_URL
}/v1-get-user-question-progress/${groupId}` }/v1-get-user-question-progress/${groupId}`,
); );
if (error) { if (error) {
@ -106,7 +106,7 @@ export function QuestionsList(props: QuestionsListProps) {
}/v1-reset-question-progress/${groupId}`, }/v1-reset-question-progress/${groupId}`,
{ {
status: type, status: type,
} },
); );
if (error) { if (error) {
@ -139,7 +139,7 @@ export function QuestionsList(props: QuestionsListProps) {
async function updateQuestionStatus( async function updateQuestionStatus(
status: QuestionProgressType, status: QuestionProgressType,
questionId: string questionId: string,
) { ) {
setIsLoading(true); setIsLoading(true);
let newProgress = userProgress || { know: [], dontKnow: [], skip: [] }; let newProgress = userProgress || { know: [], dontKnow: [], skip: [] };
@ -161,7 +161,7 @@ export function QuestionsList(props: QuestionsListProps) {
status, status,
questionId, questionId,
questionGroupId: groupId, questionGroupId: groupId,
} },
); );
if (error || !response) { if (error || !response) {
@ -173,7 +173,7 @@ export function QuestionsList(props: QuestionsListProps) {
} }
const updatedQuestionList = pendingQuestions.filter( const updatedQuestionList = pendingQuestions.filter(
(q) => q.id !== questionId (q) => q.id !== questionId,
); );
setUserProgress(newProgress); setUserProgress(newProgress);
@ -198,7 +198,7 @@ export function QuestionsList(props: QuestionsListProps) {
const hasFinished = !isLoading && hasProgress && !currQuestion; const hasFinished = !isLoading && hasProgress && !currQuestion;
return ( return (
<div className="mb-0 sm:mb-40 gap-3 text-center"> <div className="mb-0 gap-3 text-center sm:mb-40">
<QuestionsProgress <QuestionsProgress
knowCount={knowCount} knowCount={knowCount}
didNotKnowCount={dontKnowCount} didNotKnowCount={dontKnowCount}
@ -241,7 +241,7 @@ export function QuestionsList(props: QuestionsListProps) {
</div> </div>
<div <div
className={`flex flex-col gap-1 sm:gap-3 transition-opacity duration-300 sm:flex-row ${ className={`flex flex-col gap-1 transition-opacity duration-300 sm:flex-row sm:gap-3 ${
hasFinished ? 'opacity-0' : 'opacity-100' hasFinished ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
@ -249,10 +249,10 @@ export function QuestionsList(props: QuestionsListProps) {
disabled={isLoading || !currQuestion} disabled={isLoading || !currQuestion}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault() e.preventDefault();
updateQuestionStatus('know', currQuestion.id).finally(() => null); updateQuestionStatus('know', currQuestion.id).finally(() => null);
}} }}
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50" className="flex flex-1 items-center rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
> >
<CheckCircle className="mr-1 h-4 text-current" /> <CheckCircle className="mr-1 h-4 text-current" />
Already Know that Already Know that
@ -260,11 +260,11 @@ export function QuestionsList(props: QuestionsListProps) {
<button <button
onClick={() => { onClick={() => {
updateQuestionStatus('dontKnow', currQuestion.id).finally( updateQuestionStatus('dontKnow', currQuestion.id).finally(
() => null () => null,
); );
}} }}
disabled={isLoading || !currQuestion} disabled={isLoading || !currQuestion}
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50" className="flex flex-1 items-center rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
> >
<Sparkles className="mr-1 h-4 text-current" /> <Sparkles className="mr-1 h-4 text-current" />
Didn't Know that Didn't Know that
@ -275,7 +275,7 @@ export function QuestionsList(props: QuestionsListProps) {
}} }}
disabled={isLoading || !currQuestion} disabled={isLoading || !currQuestion}
data-next-question="skip" data-next-question="skip"
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-red-600 text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50" className="flex flex-1 items-center rounded-md border border-red-600 px-2 py-2 text-sm text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
> >
<SkipForward className="mr-1 h-4" /> <SkipForward className="mr-1 h-4" />
Skip Question Skip Question

@ -26,7 +26,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
const donePercentage = (totalSolved / totalCount) * 100; const donePercentage = (totalSolved / totalCount) * 100;
return ( return (
<div className="mb-3 sm:mb-5 overflow-hidden rounded-lg border border-gray-300 bg-white p-4 sm:p-6"> <div className="mb-3 overflow-hidden rounded-lg border border-gray-300 bg-white p-4 sm:mb-5 sm:p-6">
<div className="mb-3 flex items-center text-gray-600"> <div className="mb-3 flex items-center text-gray-600">
<div className="relative w-full flex-1 rounded-xl bg-gray-200 p-1"> <div className="relative w-full flex-1 rounded-xl bg-gray-200 p-1">
<div <div
@ -79,12 +79,12 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
> >
<RotateCcw className="mr-1 h-4" /> <RotateCcw className="mr-1 h-4" />
Reset Reset
<span className='inline lg:hidden'>Progress</span> <span className="inline lg:hidden">Progress</span>
</button> </button>
</div> </div>
{showLoginAlert && ( {showLoginAlert && (
<p className="-mx-6 mt-6 -mb-6 border-t bg-yellow-100 py-3 text-sm text-yellow-900"> <p className="-mx-6 -mb-6 mt-6 border-t bg-yellow-100 py-3 text-sm text-yellow-900">
You progress is not saved. Please{' '} You progress is not saved. Please{' '}
<button <button
onClick={() => { onClick={() => {

@ -24,14 +24,14 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"></div> <div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"></div>
)} )}
<h2 <h2
className="z-50 flex cursor-pointer items-center px-2 py-2.5 font-medium text-base" className="z-50 flex cursor-pointer items-center px-2 py-2.5 text-base font-medium"
aria-expanded={isAnswerVisible ? 'true' : 'false'} aria-expanded={isAnswerVisible ? 'true' : 'false'}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
setIsAnswerVisible(!isAnswerVisible); setIsAnswerVisible(!isAnswerVisible);
}} }}
> >
<span className="flex items-center flex-grow"> <span className="flex flex-grow items-center">
<GraduationCap className="mr-2 inline-block h-6 w-6" /> <GraduationCap className="mr-2 inline-block h-6 w-6" />
{question} {question}
</span> </span>
@ -61,7 +61,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
</h2> </h2>
)} )}
<div <div
className="bg-gray-100 [&>p]:text-gray-800 p-3 text-base [&>h2]:mb-2 [&>h2]:mt-5 [&>h2]:text-[17px] [&>h2]:font-medium [&>p:last-child]:mb-0 [&>p>a]:font-semibold [&>p>a]:underline [&>p>a]:underline-offset-2 [&>p]:mb-3 [&>p]:font-normal [&>p]:leading-relaxed" className="bg-gray-100 p-3 text-base [&>h2]:mb-2 [&>h2]:mt-5 [&>h2]:text-[17px] [&>h2]:font-medium [&>p:last-child]:mb-0 [&>p>a]:font-semibold [&>p>a]:underline [&>p>a]:underline-offset-2 [&>p]:mb-3 [&>p]:font-normal [&>p]:leading-relaxed [&>p]:text-gray-800"
dangerouslySetInnerHTML={{ __html: markdownToHtml(answer, false) }} dangerouslySetInnerHTML={{ __html: markdownToHtml(answer, false) }}
></div> ></div>
</div> </div>

@ -1,8 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { UserItem } from './UserItem'; import { UserItem } from './UserItem';
import { Users2 } from 'lucide-react'; import { Check, Copy, Group, UserPlus2, Users2 } from 'lucide-react';
import {httpGet} from "../../lib/http"; import { httpGet } from '../../lib/http';
import { getUser } from '../../lib/jwt.ts';
import { useCopyText } from '../../hooks/use-copy-text.ts';
import { cn } from '../../lib/classname.ts';
export type FriendshipStatus = export type FriendshipStatus =
| 'none' | 'none'
@ -41,10 +44,13 @@ type ShareFriendListProps = {
}; };
export function ShareFriendList(props: ShareFriendListProps) { export function ShareFriendList(props: ShareFriendListProps) {
const userId = getUser()?.id!;
const { setFriends, friends, sharedFriendIds, setSharedFriendIds } = props; const { setFriends, friends, sharedFriendIds, setSharedFriendIds } = props;
const toast = useToast(); const toast = useToast();
const { isCopied, copyText } = useCopyText();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isAddingFriend, setIsAddingFriend] = useState(false);
async function loadFriends() { async function loadFriends() {
if (friends.length > 0) { if (friends.length > 0) {
@ -53,7 +59,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
setIsLoading(true); setIsLoading(true);
const { response, error } = await httpGet<ListFriendsResponse>( const { response, error } = await httpGet<ListFriendsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends` `${import.meta.env.PUBLIC_API_URL}/v1-list-friends`,
); );
if (error || !response) { if (error || !response) {
@ -87,6 +93,10 @@ export function ShareFriendList(props: ShareFriendListProps) {
</ul> </ul>
); );
const isDev = import.meta.env.DEV;
const baseWebUrl = isDev ? 'http://localhost:3000' : 'https://roadmap.sh';
const befriendUrl = `${baseWebUrl}/befriend?u=${userId}`;
return ( return (
<> <>
{(friends.length > 0 || isLoading) && ( {(friends.length > 0 || isLoading) && (
@ -112,6 +122,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
{loadingFriends} {loadingFriends}
{friends.length > 0 && !isLoading && ( {friends.length > 0 && !isLoading && (
<>
<ul className="mt-2 grid grid-cols-3 gap-1.5"> <ul className="mt-2 grid grid-cols-3 gap-1.5">
{friends.map((friend) => { {friends.map((friend) => {
const isSelected = sharedFriendIds?.includes(friend.userId); const isSelected = sharedFriendIds?.includes(friend.userId);
@ -127,7 +138,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
onClick={() => { onClick={() => {
if (isSelected) { if (isSelected) {
setSharedFriendIds( setSharedFriendIds(
sharedFriendIds.filter((id) => id !== friend.userId) sharedFriendIds.filter((id) => id !== friend.userId),
); );
} else { } else {
setSharedFriendIds([...sharedFriendIds, friend.userId]); setSharedFriendIds([...sharedFriendIds, friend.userId]);
@ -138,6 +149,58 @@ export function ShareFriendList(props: ShareFriendListProps) {
); );
})} })}
</ul> </ul>
{!isAddingFriend && (
<p className="mt-6 text-sm text-gray-600">
Don't see a Friend?{' '}
<button
onClick={() => {
setIsAddingFriend(true);
}}
className="font-semibold text-gray-900 underline"
>
Add them
</button>
</p>
)}
{isAddingFriend && (
<div className="-mx-4 -mb-4 mt-6 border-t bg-gray-50 px-4 py-4">
<p className="mb-1.5 flex items-center gap-1 text-sm text-gray-800">
<UserPlus2 className="text-gray-500" size="20px" />
Share the link below with your friends to invite them
</p>
<div className="relative">
<input
readOnly
type="text"
value={befriendUrl}
onClick={(e) => {
e.preventDefault();
(e.target as HTMLInputElement).select();
copyText(befriendUrl);
}}
className={cn(
'w-full rounded-md border px-2 py-2 text-sm focus:shadow-none focus:outline-0',
{
'border-green-400 bg-green-50': isCopied,
},
)}
/>
<button
onClick={() => copyText(befriendUrl)}
className="absolute bottom-0 right-0 top-0 flex items-center px-2.5"
>
{isCopied ? (
<span className="flex items-center gap-1 text-sm font-medium text-green-600">
<Check className="text-green-600" size="18px" /> Copied
</span>
) : (
<Copy className="text-gray-400" size="18px" />
)}
</button>
</div>
</div>
)}
</>
)} )}
{friends.length === 0 && !isLoading && ( {friends.length === 0 && !isLoading && (
@ -148,7 +211,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
<a <a
target="_blank" target="_blank"
className="underline underline-offset-2" className="underline underline-offset-2"
href={`${import.meta.env.PUBLIC_ROADMAP_WEB_URL}/account/friends`} href={`/account/friends`}
> >
Invite your friends to share roadmaps with. Invite your friends to share roadmaps with.
</a> </a>

@ -1,4 +1,4 @@
import { CheckCircle, CheckCircle2, CheckIcon } from 'lucide-react'; import { CheckCircle } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt.ts'; import { isLoggedIn } from '../../lib/jwt.ts';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';

@ -1,4 +1,4 @@
import { Check, CheckCircle, Copy, Sparkles } from 'lucide-react'; import { Copy } from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text.ts'; import { useCopyText } from '../../hooks/use-copy-text.ts';
import { cn } from '../../lib/classname.ts'; import { cn } from '../../lib/classname.ts';
import { isLoggedIn } from '../../lib/jwt.ts'; import { isLoggedIn } from '../../lib/jwt.ts';
@ -76,7 +76,7 @@ export function TeamPricing() {
copyText(teamEmail); copyText(teamEmail);
}} }}
className={cn( className={cn(
'relative flex items-center justify-between gap-3 overflow-hidden rounded-md border border-black bg-white px-4 py-2 text-black hover:bg-gray-100' 'relative flex items-center justify-between gap-3 overflow-hidden rounded-md border border-black bg-white px-4 py-2 text-black hover:bg-gray-100',
)} )}
> >
{teamEmail} {teamEmail}
@ -91,7 +91,7 @@ export function TeamPricing() {
{ {
'top-full': !isCopied, 'top-full': !isCopied,
'top-0': isCopied, 'top-0': isCopied,
} },
)} )}
> >
Email copied! Email copied!

@ -0,0 +1,294 @@
import {
useCallback,
useEffect,
useState,
type MouseEvent,
useRef,
} from 'react';
import { Spinner } from '../ReactIcons/Spinner';
import '../FrameRenderer/FrameRenderer.css';
import type { TeamMember } from './TeamProgressPage';
import { httpGet } from '../../lib/http';
import {
renderTopicProgress,
type ResourceProgressType,
type ResourceType,
updateResourceProgress,
} from '../../lib/resource-progress';
import CloseIcon from '../../icons/close.svg';
import { useToast } from '../../hooks/use-toast';
import { useAuth } from '../../hooks/use-auth';
import { pageProgressMessage } from '../../stores/page';
import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap';
import { ReadonlyEditor } from '../../../editor/readonly-editor';
import type { Node } from 'reactflow';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { MemberProgressModalHeader } from './MemberProgressModalHeader';
export type ProgressMapProps = {
member: TeamMember;
teamId: string;
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
onClose: () => void;
onShowMyProgress: () => void;
isCustomResource?: boolean;
};
export type MemberProgressResponse = {
removed: string[];
done: string[];
learning: string[];
skipped: string[];
};
export function MemberCustomProgressModal(props: ProgressMapProps) {
const {
resourceId,
member,
resourceType,
onShowMyProgress,
teamId,
onClose,
} = props;
const user = useAuth();
const isCurrentUser = user?.email === member.email;
const popupBodyEl = useRef<HTMLDivElement>(null);
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
const [memberProgress, setMemberProgress] =
useState<MemberProgressResponse>();
const [isLoading, setIsLoading] = useState(true);
const toast = useToast();
useKeydown('Escape', () => onClose());
useOutsideClick(popupBodyEl, () => onClose());
async function getMemberProgress(
teamId: string,
memberId: string,
resourceType: string,
resourceId: string,
) {
const { error, response } = await httpGet<MemberProgressResponse>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-get-member-resource-progress/${teamId}/${memberId}?resourceType=${resourceType}&resourceId=${resourceId}`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to get member progress');
return;
}
setMemberProgress(response);
return response;
}
async function getRoadmap() {
const { response, error } = await httpGet<GetRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${resourceId}`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to load roadmap');
return;
}
setRoadmap(response);
return response;
}
useEffect(() => {
if (!resourceId || !resourceType || !teamId) {
return;
}
setIsLoading(true);
Promise.all([
getRoadmap(),
getMemberProgress(teamId, member._id, resourceType, resourceId),
])
.then(() => {})
.catch((err) => {
console.error(err);
toast.error(err?.message || 'Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, [member]);
function updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
if (!resourceId || !resourceType || !isCurrentUser) {
return;
}
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: resourceId,
resourceType: resourceType as ResourceType,
topicId,
},
newStatus,
)
.then(() => {
renderTopicProgress(topicId, newStatus);
getMemberProgress(teamId, member._id, resourceType, resourceId).then(
(data) => {
setMemberProgress(data);
},
);
})
.catch((err) => {
alert('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
});
return;
}
const handleTopicRightClick = useCallback((e: MouseEvent, node: Node) => {
if (!isCurrentUser) {
return;
}
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
const isCurrentStatusDone = target?.classList.contains('done');
updateTopicStatus(node.id, isCurrentStatusDone ? 'pending' : 'done');
}, []);
const handleTopicShiftClick = useCallback((e: MouseEvent, node: Node) => {
if (!isCurrentUser) {
return;
}
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
const isCurrentStatusLearning = target?.classList.contains('learning');
updateTopicStatus(
node.id,
isCurrentStatusLearning ? 'pending' : 'learning',
);
}, []);
const handleTopicAltClick = useCallback((e: MouseEvent, node: Node) => {
if (!isCurrentUser) {
return;
}
const target = e?.currentTarget as HTMLDivElement;
if (!target) {
return;
}
const isCurrentStatusSkipped = target?.classList.contains('skipped');
updateTopicStatus(node.id, isCurrentStatusSkipped ? 'pending' : 'skipped');
}, []);
const handleLinkClick = useCallback((linkId: string, href: string) => {
if (!href || !isCurrentUser) {
return;
}
const isExternalLink = href.startsWith('http');
if (isExternalLink) {
window.open(href, '_blank');
} else {
window.location.href = href;
}
}, []);
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div
id="original-roadmap"
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
>
<div
className="relative rounded-lg bg-white pt-[1px] shadow"
ref={popupBodyEl}
>
<MemberProgressModalHeader
resourceId={resourceId}
member={member}
progress={memberProgress}
isCurrentUser={isCurrentUser}
onShowMyProgress={onShowMyProgress}
isLoading={isLoading}
/>
{!isLoading && roadmap && (
<div className="px-4 pb-2">
<ReadonlyEditor
variant="modal"
roadmap={roadmap!}
className="min-h-[400px]"
onRendered={() => {
const {
removed = [],
done = [],
learning = [],
skipped = [],
} = memberProgress || {};
done.forEach((id: string) => renderTopicProgress(id, 'done'));
learning.forEach((id: string) =>
renderTopicProgress(id, 'learning'),
);
skipped.forEach((id: string) =>
renderTopicProgress(id, 'skipped'),
);
removed.forEach((id: string) =>
renderTopicProgress(id, 'removed'),
);
}}
onTopicRightClick={handleTopicRightClick}
onTopicShiftClick={handleTopicShiftClick}
onTopicAltClick={handleTopicAltClick}
onButtonNodeClick={handleLinkClick}
onLinkClick={handleLinkClick}
fontFamily="Balsamiq Sans"
fontURL="/fonts/balsamiq.woff2"
/>
</div>
)}
{isLoading && (
<div className="flex w-full justify-center">
<Spinner
isDualRing={false}
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
/>
</div>
)}
<button
type="button"
className={`absolute right-2.5 top-3 z-50 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:text-gray-900 lg:hidden ${
isCurrentUser ? 'hover:bg-gray-800' : 'hover:bg-gray-100'
}`}
onClick={onClose}
>
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
<span className="sr-only">Close modal</span>
</button>
</div>
</div>
</div>
);
}

@ -16,13 +16,7 @@ import CloseIcon from '../../icons/close.svg';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import { useAuth } from '../../hooks/use-auth'; import { useAuth } from '../../hooks/use-auth';
import { pageProgressMessage } from '../../stores/page'; import { pageProgressMessage } from '../../stores/page';
import { useStore } from '@nanostores/react'; import { MemberProgressModalHeader } from './MemberProgressModalHeader';
import { $currentTeam } from '../../stores/team';
import { renderFlowJSON } from '../../../renderer/renderer';
import {
allowedClickableNodeTypes,
getNodeDetails,
} from '../CustomRoadmap/RoadmapRenderer';
export type ProgressMapProps = { export type ProgressMapProps = {
member: TeamMember; member: TeamMember;
@ -49,7 +43,6 @@ export function MemberProgressModal(props: ProgressMapProps) {
onShowMyProgress, onShowMyProgress,
teamId, teamId,
onClose, onClose,
isCustomResource,
} = props; } = props;
const user = useAuth(); const user = useAuth();
const isCurrentUser = user?.email === member.email; const isCurrentUser = user?.email === member.email;
@ -70,12 +63,6 @@ export function MemberProgressModal(props: ProgressMapProps) {
resourceJsonUrl += `/best-practices/${resourceId}.json`; resourceJsonUrl += `/best-practices/${resourceId}.json`;
} }
if (isCustomResource) {
resourceJsonUrl = `${
import.meta.env.PUBLIC_API_URL
}/v1-get-roadmap/${resourceId}`;
}
async function getMemberProgress( async function getMemberProgress(
teamId: string, teamId: string,
memberId: string, memberId: string,
@ -98,28 +85,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
} }
async function renderResource(jsonUrl: string) { async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl, { const res = await fetch(jsonUrl, {});
...(isCustomResource && {
credentials: 'include',
}),
});
const json = await res.json(); const json = await res.json();
let svg: SVGElement | null = null; const svg: SVGElement | null = await wireframeJSONToSVG(json, {
if (isCustomResource) {
svg = await renderFlowJSON(
{
nodes: json.nodes,
edges: json.edges,
},
{
fontURL: '/fonts/balsamiq.woff2',
}
);
} else {
svg = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2', fontURL: '/fonts/balsamiq.woff2',
}); });
}
containerEl.current?.replaceChildren(svg); containerEl.current?.replaceChildren(svg);
} }
@ -215,29 +185,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
return; return;
} }
let topicId = '';
if (isCustomResource) {
const { nodeId, nodeType } = getNodeDetails(e.target as SVGElement) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType)
) {
return;
}
if (nodeType === 'button') {
return;
}
topicId = nodeId;
} else {
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ''; const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) { if (!groupId) {
return; return;
} }
topicId = groupId.replace(/^\d+-/, ''); const topicId = groupId.replace(/^\d+-/, '');
}
if (targetGroup.classList.contains('removed')) { if (targetGroup.classList.contains('removed')) {
e.preventDefault(); e.preventDefault();
@ -255,29 +207,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
if (!targetGroup) { if (!targetGroup) {
return; return;
} }
let topicId = '';
if (isCustomResource) {
const { nodeId, nodeType } = getNodeDetails(e.target as SVGElement) || {};
if (
!nodeId ||
!nodeType ||
!allowedClickableNodeTypes.includes(nodeType)
) {
return;
}
if (nodeType === 'button') {
return;
}
topicId = nodeId;
} else {
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : ''; const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) { if (!groupId) {
return; return;
} }
topicId = groupId.replace(/^\d+-/, ''); const topicId = groupId.replace(/^\d+-/, '');
}
if (targetGroup.classList.contains('removed')) { if (targetGroup.classList.contains('removed')) {
return; return;
@ -321,136 +255,24 @@ export function MemberProgressModal(props: ProgressMapProps) {
}; };
}, [member]); }, [member]);
const removedTopics = memberProgress?.removed || [];
const memberDone =
memberProgress?.done.filter((id) => !removedTopics.includes(id)).length ||
0;
const memberLearning =
memberProgress?.learning.filter((id) => !removedTopics.includes(id))
.length || 0;
const memberSkipped =
memberProgress?.skipped.filter((id) => !removedTopics.includes(id))
.length || 0;
const currProgress = member.progress.find((p) => p.resourceId === resourceId);
const memberTotal = currProgress?.total || 0;
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
return ( return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"> <div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div <div
id={isCustomResource ? 'original-roadmap' : 'customized-roadmap'} id={'customized-roadmap'}
className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto" className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto"
> >
<div <div
ref={popupBodyEl} ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white pt-[1px] shadow" className="popup-body relative rounded-lg bg-white pt-[1px] shadow"
> >
{isCurrentUser && ( <MemberProgressModalHeader
<div className="sticky top-1 mx-1 mb-0 mt-1 rounded-xl bg-gray-900 p-4 text-gray-300"> resourceId={resourceId}
<h2 className={'mb-1.5 text-base'}> member={member}
Follow the Instructions below to update your progress progress={memberProgress}
</h2> isCurrentUser={isCurrentUser}
<ul className="flex flex-col gap-1"> onShowMyProgress={onShowMyProgress}
<li className="leading-loose"> isLoading={isLoading}
<kbd className="rounded-md bg-yellow-200 px-2 py-1.5 text-xs text-gray-900"> />
Right Mouse Click
</kbd>{' '}
on a topic to mark as{' '}
<span className={'font-medium text-white'}>Done</span>.
</li>
<li className="leading-loose">
<kbd className="rounded-md bg-yellow-200 px-2 py-1.5 text-xs text-gray-900">
Shift
</kbd>{' '}
+{' '}
<kbd className="rounded-md bg-yellow-200 px-2 py-1.5 text-xs text-gray-900">
Click
</kbd>{' '}
on a topic to mark as{' '}
<span className="font-medium text-white">In progress</span>.
</li>
</ul>
</div>
)}
<div className="p-4">
{!isCurrentUser && (
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
{member.name}'s Progress
</h2>
<p
className={
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
}
>
You are looking at {member.name}'s progress.{' '}
<button
className="text-blue-600 underline"
onClick={onShowMyProgress}
>
View your progress
</button>
.
</p>
<p className={'block text-gray-500 md:hidden'}>
<button
className="text-blue-600 underline"
onClick={onShowMyProgress}
>
View your progress.
</button>
</p>
</div>
)}
<p
className={`-mx-4 mb-3 flex items-center justify-start border-b border-t px-4 py-2 text-sm sm:hidden ${
isLoading ? 'striped-loader' : ''
}`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{memberDone}</span> of <span>{memberTotal}</span> done
</span>
</p>
<p
className={`-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex ${
isLoading ? 'striped-loader' : ''
}`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{memberDone}</span> completed
</span>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-learning="">{memberLearning}</span> in
progress
</span>
{memberSkipped > 0 && (
<>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-skipped="">{memberSkipped}</span>{' '}
skipped
</span>
</>
)}
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-total="">{memberTotal}</span> Total
</span>
</p>
</div>
<div <div
id={'resource-svg-wrap'} id={'resource-svg-wrap'}

@ -0,0 +1,148 @@
import type { MemberProgressResponse } from './MemberCustomProgressModal';
import type { TeamMember } from './TeamProgressPage';
type MemberProgressModalHeaderProps = {
member: TeamMember;
progress?: MemberProgressResponse;
resourceId: string;
isLoading: boolean;
onShowMyProgress: () => void;
isCurrentUser: boolean;
};
export function MemberProgressModalHeader(
props: MemberProgressModalHeaderProps
) {
const {
progress: memberProgress,
member,
resourceId,
isLoading,
onShowMyProgress,
isCurrentUser,
} = props;
const removedTopics = memberProgress?.removed || [];
const memberDone =
memberProgress?.done.filter((id) => !removedTopics.includes(id)).length ||
0;
const memberLearning =
memberProgress?.learning.filter((id) => !removedTopics.includes(id))
.length || 0;
const memberSkipped =
memberProgress?.skipped.filter((id) => !removedTopics.includes(id))
.length || 0;
const currProgress = member.progress.find((p) => p.resourceId === resourceId);
const memberTotal = currProgress?.total || 0;
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
return (
<>
{isCurrentUser && (
<div className="sticky top-1 z-50 mx-1 mb-0 mt-1 rounded-xl bg-gray-900 p-4 text-gray-300">
<h2 className={'mb-1.5 text-base'}>
Follow the Instructions below to update your progress
</h2>
<ul className="flex flex-col gap-1">
<li className="leading-loose">
<kbd className="rounded-md bg-yellow-200 px-2 py-1.5 text-xs text-gray-900">
Right Mouse Click
</kbd>{' '}
on a topic to mark as{' '}
<span className={'font-medium text-white'}>Done</span>.
</li>
<li className="leading-loose">
<kbd className="rounded-md bg-yellow-200 px-2 py-1.5 text-xs text-gray-900">
Shift
</kbd>{' '}
+{' '}
<kbd className="rounded-md bg-yellow-200 px-2 py-1.5 text-xs text-gray-900">
Click
</kbd>{' '}
on a topic to mark as{' '}
<span className="font-medium text-white">In progress</span>.
</li>
</ul>
</div>
)}
<div className="p-4">
{!isCurrentUser && (
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
{member.name}'s Progress
</h2>
<p
className={
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
}
>
You are looking at {member.name}'s progress.{' '}
<button
className="text-blue-600 underline"
onClick={onShowMyProgress}
>
View your progress
</button>
.
</p>
<p className={'block text-gray-500 md:hidden'}>
<button
className="text-blue-600 underline"
onClick={onShowMyProgress}
>
View your progress.
</button>
</p>
</div>
)}
<p
className={`-mx-4 mb-3 flex items-center justify-start border-b border-t px-4 py-2 text-sm sm:hidden ${
isLoading ? 'striped-loader' : ''
}`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{memberDone}</span> of <span>{memberTotal}</span> done
</span>
</p>
<p
className={`-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex ${
isLoading ? 'striped-loader' : ''
}`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{memberDone}</span> completed
</span>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-learning="">{memberLearning}</span> in progress
</span>
{memberSkipped > 0 && (
<>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-skipped="">{memberSkipped}</span> skipped
</span>
</>
)}
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-total="">{memberTotal}</span> Total
</span>
</p>
</div>
</>
);
}

@ -9,6 +9,7 @@ import { GroupRoadmapItem } from './GroupRoadmapItem';
import { getUrlParams, setUrlParams } from '../../lib/browser'; import { getUrlParams, setUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth'; import { useAuth } from '../../hooks/use-auth';
import { MemberProgressModal } from './MemberProgressModal'; import { MemberProgressModal } from './MemberProgressModal';
import { MemberCustomProgressModal } from './MemberCustomProgressModal';
export type UserProgress = { export type UserProgress = {
resourceTitle: string; resourceTitle: string;
@ -152,10 +153,15 @@ export function TeamProgressPage() {
return null; return null;
} }
const ProgressModal =
showMemberProgress && !showMemberProgress.isCustomResource
? MemberProgressModal
: MemberCustomProgressModal;
return ( return (
<div> <div>
{showMemberProgress && ( {showMemberProgress && (
<MemberProgressModal <ProgressModal
member={showMemberProgress.member} member={showMemberProgress.member}
teamId={teamId} teamId={teamId}
resourceId={showMemberProgress.resourceId} resourceId={showMemberProgress.resourceId}

@ -8,13 +8,13 @@ import { useOutsideClick } from '../../hooks/use-outside-click';
import { useToggleTopic } from '../../hooks/use-toggle-topic'; import { useToggleTopic } from '../../hooks/use-toggle-topic';
import { httpGet } from '../../lib/http'; import { httpGet } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt'; import { isLoggedIn } from '../../lib/jwt';
import type { ResourceType } from '../../lib/resource-progress';
import { import {
isTopicDone, isTopicDone,
refreshProgressCounters, refreshProgressCounters,
renderTopicProgress, renderTopicProgress,
updateResourceProgress as updateResourceProgressApi, updateResourceProgress as updateResourceProgressApi,
} from '../../lib/resource-progress'; } from '../../lib/resource-progress';
import type { ResourceType } from '../../lib/resource-progress';
import { pageProgressMessage, sponsorHidden } from '../../stores/page'; import { pageProgressMessage, sponsorHidden } from '../../stores/page';
import { TopicProgressButton } from './TopicProgressButton'; import { TopicProgressButton } from './TopicProgressButton';
import { ContributionForm } from './ContributionForm'; import { ContributionForm } from './ContributionForm';
@ -95,13 +95,13 @@ export function TopicDetail(props: TopicDetailProps) {
resourceId, resourceId,
resourceType, resourceType,
}, },
oldIsDone ? 'pending' : 'done' oldIsDone ? 'pending' : 'done',
) ),
) )
.then(({ done = [] }) => { .then(({ done = [] }) => {
renderTopicProgress( renderTopicProgress(
topicId, topicId,
done.includes(topicId) ? 'done' : 'pending' done.includes(topicId) ? 'done' : 'pending',
); );
refreshProgressCounters(); refreshProgressCounters();
}) })
@ -149,7 +149,7 @@ export function TopicDetail(props: TopicDetailProps) {
Accept: 'text/html', Accept: 'text/html',
}, },
}), }),
} },
) )
.then(({ response }) => { .then(({ response }) => {
if (!response) { if (!response) {
@ -163,7 +163,7 @@ export function TopicDetail(props: TopicDetailProps) {
// We only need the inner HTML of the #main-content // We only need the inner HTML of the #main-content
const node = new DOMParser().parseFromString( const node = new DOMParser().parseFromString(
response as string, response as string,
'text/html' 'text/html',
); );
topicHtml = node?.getElementById('main-content')?.outerHTML || ''; topicHtml = node?.getElementById('main-content')?.outerHTML || '';
} else { } else {
@ -171,7 +171,7 @@ export function TopicDetail(props: TopicDetailProps) {
setTopicTitle((response as RoadmapContentDocument)?.title || ''); setTopicTitle((response as RoadmapContentDocument)?.title || '');
topicHtml = markdownToHtml( topicHtml = markdownToHtml(
(response as RoadmapContentDocument)?.description || '', (response as RoadmapContentDocument)?.description || '',
false false,
); );
} }
@ -279,7 +279,7 @@ export function TopicDetail(props: TopicDetailProps) {
<span <span
className={cn( className={cn(
'mr-2 inline-block rounded px-1.5 py-1 text-xs uppercase no-underline', 'mr-2 inline-block rounded px-1.5 py-1 text-xs uppercase no-underline',
linkTypes[link.type] linkTypes[link.type],
)} )}
> >
{link.type.charAt(0).toUpperCase() + {link.type.charAt(0).toUpperCase() +

@ -0,0 +1,37 @@
import { ErrorIcon } from "../ReactIcons/ErrorIcon";
import { Spinner } from "../ReactIcons/Spinner";
type ProgressLoadingErrorProps = {
isLoading: boolean;
error: string;
}
export function ProgressLoadingError(props: ProgressLoadingErrorProps) {
const { isLoading, error } = props;
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto flex h-full w-full items-center justify-center">
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
<div className="flex items-center">
{isLoading && (
<>
<Spinner className="h-6 w-6" isDualRing={false} />
<span className="ml-3 text-lg font-semibold">
Loading user progress...
</span>
</>
)}
{error && (
<>
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
<span className="ml-3 text-lg font-semibold">{error}</span>
</>
)}
</div>
</div>
</div>
</div>
)
}

@ -0,0 +1,218 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { httpGet } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress';
import { topicSelectorAll } from '../../lib/resource-progress';
import CloseIcon from '../../icons/close.svg';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth';
import type { GetRoadmapResponse } from '../CustomRoadmap/CustomRoadmap';
import { ReadonlyEditor } from '../../../editor/readonly-editor';
import { ProgressLoadingError } from './ProgressLoadingError';
import { UserProgressModalHeader } from './UserProgressModalHeader';
export type ProgressMapProps = {
userId?: string;
resourceId: string;
resourceType: ResourceType;
onClose?: () => void;
isCustomResource?: boolean;
};
type UserProgressResponse = {
user: {
_id: string;
name: string;
};
progress: {
total: number;
done: string[];
learning: string[];
skipped: string[];
};
};
export function UserCustomProgressModal(props: ProgressMapProps) {
const {
resourceId,
resourceType,
userId: propUserId,
onClose: onModalClose,
isCustomResource,
} = props;
const { s: userId = propUserId } = getUrlParams();
if (!userId) {
return null;
}
const resourceSvgEl = useRef<HTMLDivElement>(null);
const popupBodyEl = useRef<HTMLDivElement>(null);
const currentUser = useAuth();
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
const [showModal, setShowModal] = useState(!!userId);
const [progressResponse, setProgressResponse] =
useState<UserProgressResponse>();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
async function getUserProgress(
userId: string,
resourceType: string,
resourceId: string,
): Promise<UserProgressResponse | undefined> {
const { error, response } = await httpGet<UserProgressResponse>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-get-user-progress/${userId}?resourceType=${resourceType}&resourceId=${resourceId}`,
);
if (error || !response) {
throw error || new Error('Something went wrong. Please try again!');
}
return response;
}
async function getRoadmapSVG(): Promise<GetRoadmapResponse> {
const { error, response: roadmapData } = await httpGet<GetRoadmapResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${resourceId}`,
);
if (error || !roadmapData) {
throw error || new Error('Something went wrong. Please try again!');
}
setRoadmap(roadmapData);
return roadmapData;
}
function onClose() {
deleteUrlParam('s');
setError('');
setShowModal(false);
if (onModalClose) {
onModalClose();
} else {
window.location.reload();
}
}
useKeydown('Escape', () => {
onClose();
});
useOutsideClick(popupBodyEl, () => {
onClose();
});
useEffect(() => {
if (!resourceId || !resourceType || !userId) {
return;
}
setIsLoading(true);
Promise.all([
getRoadmapSVG(),
getUserProgress(userId, resourceType, resourceId),
])
.then(([_, user]) => {
if (!user) {
return;
}
setProgressResponse(user);
})
.catch((err) => {
setError(err?.message || 'Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, [userId]);
if (currentUser?.id === userId) {
deleteUrlParam('s');
return null;
}
if (!showModal) {
return null;
}
if (isLoading || error) {
return <ProgressLoadingError isLoading={isLoading} error={error || ''} />;
}
return (
<div
id={'user-progress-modal'}
className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50"
>
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div
ref={popupBodyEl}
className={`popup-body relative rounded-lg bg-white pt-[1px] shadow`}
>
<UserProgressModalHeader
isLoading={isLoading}
progressResponse={progressResponse}
/>
<div ref={resourceSvgEl} className="px-4 pb-2">
<ReadonlyEditor
variant="modal"
roadmap={roadmap!}
className="min-h-[400px]"
onRendered={(wrapperRef: RefObject<HTMLDivElement>) => {
const {
done = [],
learning = [],
skipped = [],
} = progressResponse?.progress || {};
done?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('done');
},
);
});
learning?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('learning');
},
);
});
skipped?.forEach((topicId: string) => {
topicSelectorAll(topicId, wrapperRef?.current!).forEach(
(el) => {
el.classList.add('skipped');
},
);
});
}}
fontFamily="Balsamiq Sans"
fontURL="/fonts/balsamiq.woff2"
/>
</div>
<button
type="button"
className={`absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-gray-100 bg-transparent p-1.5 text-sm text-gray-400 hover:text-gray-900 lg:hidden`}
onClick={onClose}
>
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
<span className="sr-only">Close modal</span>
</button>
</div>
</div>
</div>
);
}

@ -9,9 +9,8 @@ import { topicSelectorAll } from '../../lib/resource-progress';
import CloseIcon from '../../icons/close.svg'; import CloseIcon from '../../icons/close.svg';
import { deleteUrlParam, getUrlParams } from '../../lib/browser'; import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { useAuth } from '../../hooks/use-auth'; import { useAuth } from '../../hooks/use-auth';
import { Spinner } from '../ReactIcons/Spinner'; import { ProgressLoadingError } from './ProgressLoadingError';
import { ErrorIcon } from '../ReactIcons/ErrorIcon'; import { UserProgressModalHeader } from './UserProgressModalHeader';
import { renderFlowJSON } from '../../../renderer/renderer';
export type ProgressMapProps = { export type ProgressMapProps = {
userId?: string; userId?: string;
@ -21,7 +20,7 @@ export type ProgressMapProps = {
isCustomResource?: boolean; isCustomResource?: boolean;
}; };
type UserProgressResponse = { export type UserProgressResponse = {
user: { user: {
_id: string; _id: string;
name: string; name: string;
@ -40,7 +39,6 @@ export function UserProgressModal(props: ProgressMapProps) {
resourceType, resourceType,
userId: propUserId, userId: propUserId,
onClose: onModalClose, onClose: onModalClose,
isCustomResource,
} = props; } = props;
const { s: userId = propUserId } = getUrlParams(); const { s: userId = propUserId } = getUrlParams();
@ -69,12 +67,6 @@ export function UserProgressModal(props: ProgressMapProps) {
resourceJsonUrl += `/best-practices/${resourceId}.json`; resourceJsonUrl += `/best-practices/${resourceId}.json`;
} }
if (isCustomResource) {
resourceJsonUrl = `${
import.meta.env.PUBLIC_API_URL
}/v1-get-roadmap/${resourceId}`;
}
async function getUserProgress( async function getUserProgress(
userId: string, userId: string,
resourceType: string, resourceType: string,
@ -101,12 +93,6 @@ export function UserProgressModal(props: ProgressMapProps) {
throw error || new Error('Something went wrong. Please try again!'); throw error || new Error('Something went wrong. Please try again!');
} }
if (isCustomResource) {
return await renderFlowJSON({
nodes: roadmapJson?.nodes || [],
edges: roadmapJson?.edges || [],
});
}
return await wireframeJSONToSVG(roadmapJson, { return await wireframeJSONToSVG(roadmapJson, {
fontURL: '/fonts/balsamiq.woff2', fontURL: '/fonts/balsamiq.woff2',
}); });
@ -180,14 +166,6 @@ export function UserProgressModal(props: ProgressMapProps) {
el.removeAttribute('data-group-id'); el.removeAttribute('data-group-id');
}); });
svg.querySelectorAll('[data-node-id]').forEach((el) => {
el.removeAttribute('data-node-id');
});
svg.querySelectorAll('[data-type]').forEach((el) => {
el.removeAttribute('data-type');
});
setResourceSvg(svg); setResourceSvg(svg);
setProgressResponse(user); setProgressResponse(user);
}) })
@ -199,16 +177,6 @@ export function UserProgressModal(props: ProgressMapProps) {
}); });
}, [userId]); }, [userId]);
const user = progressResponse?.user;
const progress = progressResponse?.progress;
const userProgressTotal = progress?.total || 0;
const userDone = progress?.done?.length || 0;
const progressPercentage =
Math.round((userDone / userProgressTotal) * 100) || 0;
const userLearning = progress?.learning?.length || 0;
const userSkipped = progress?.skipped?.length || 0;
if (currentUser?.id === userId) { if (currentUser?.id === userId) {
deleteUrlParam('s'); deleteUrlParam('s');
return null; return null;
@ -219,31 +187,7 @@ export function UserProgressModal(props: ProgressMapProps) {
} }
if (isLoading || error) { if (isLoading || error) {
return ( return <ProgressLoadingError isLoading={isLoading} error={error} />;
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto flex h-full w-full items-center justify-center">
<div className="popup-body relative rounded-lg bg-white p-5 shadow">
<div className="flex items-center">
{isLoading && (
<>
<Spinner className="h-6 w-6" isDualRing={false} />
<span className="ml-3 text-lg font-semibold">
Loading user progress...
</span>
</>
)}
{error && (
<>
<ErrorIcon additionalClasses="h-6 w-6 text-red-500" />
<span className="ml-3 text-lg font-semibold">{error}</span>
</>
)}
</div>
</div>
</div>
</div>
);
} }
return ( return (
@ -256,62 +200,10 @@ export function UserProgressModal(props: ProgressMapProps) {
ref={popupBodyEl} ref={popupBodyEl}
className={`popup-body relative rounded-lg bg-white pt-[1px] shadow`} className={`popup-body relative rounded-lg bg-white pt-[1px] shadow`}
> >
<div className="p-4"> <UserProgressModalHeader
<div className="mb-5 mt-0 min-h-[28px] text-left sm:text-center md:mt-4 md:h-[60px]"> isLoading={isLoading}
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}> progressResponse={progressResponse}
{user?.name}'s Progress />
</h2>
<p
className={
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
}
>
You can close this popup and start tracking your progress.
</p>
</div>
<p
className={`-mx-4 mb-3 flex items-center justify-start border-b border-t px-4 py-2 text-sm sm:hidden`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{userDone}</span> of <span>{userProgressTotal}</span> done
</span>
</p>
<p
className={`-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex ${
isLoading ? 'striped-loader' : ''
}`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{userDone}</span> completed
</span>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span>{userLearning}</span> in progress
</span>
{userSkipped > 0 && (
<>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span>{userSkipped}</span> skipped
</span>
</>
)}
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span>{userProgressTotal}</span> Total
</span>
</p>
</div>
<div <div
ref={resourceSvgEl} ref={resourceSvgEl}

@ -0,0 +1,79 @@
import type { UserProgressResponse } from './UserProgressModal';
type UserProgressModalHeaderProps = {
isLoading: boolean;
progressResponse: UserProgressResponse | undefined;
};
export function UserProgressModalHeader(props: UserProgressModalHeaderProps) {
const { isLoading, progressResponse } = props;
const user = progressResponse?.user;
const progress = progressResponse?.progress;
const userProgressTotal = progress?.total || 0;
const userDone = progress?.done?.length || 0;
const progressPercentage =
Math.round((userDone / userProgressTotal) * 100) || 0;
const userLearning = progress?.learning?.length || 0;
const userSkipped = progress?.skipped?.length || 0;
return (
<div className="p-4">
<div className="mb-5 mt-0 min-h-[28px] text-left sm:text-center md:mt-4 md:h-[60px]">
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
{user?.name}'s Progress
</h2>
<p
className={
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
}
>
You can close this popup and start tracking your progress.
</p>
</div>
<p
className={`-mx-4 mb-3 flex items-center justify-start border-b border-t px-4 py-2 text-sm sm:hidden`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{userDone}</span> of <span>{userProgressTotal}</span> done
</span>
</p>
<p
className={`-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex ${
isLoading ? 'striped-loader' : ''
}`}
>
<span className="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{userDone}</span> completed
</span>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span>{userLearning}</span> in progress
</span>
{userSkipped > 0 && (
<>
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span>{userSkipped}</span> skipped
</span>
</>
)}
<span className="mx-1.5 text-gray-400">·</span>
<span>
<span>{userProgressTotal}</span> Total
</span>
</p>
</div>
);
}

1
src/env.d.ts vendored

@ -1,4 +1,5 @@
/// <reference types="astro/client" /> /// <reference types="astro/client" />
import 'astro/client';
interface ImportMetaEnv { interface ImportMetaEnv {
GITHUB_SHA: string; GITHUB_SHA: string;

@ -191,7 +191,7 @@ export function setResourceProgress(
export function topicSelectorAll( export function topicSelectorAll(
topicId: string, topicId: string,
parentElement: Document | SVGElement = document parentElement: Document | SVGElement | HTMLDivElement = document
): Element[] { ): Element[] {
const matchingElements: Element[] = []; const matchingElements: Element[] = [];
@ -213,6 +213,7 @@ export function topicSelectorAll(
`[data-group-id="${topicId}"]`, // Elements with exact match of the topic id `[data-group-id="${topicId}"]`, // Elements with exact match of the topic id
`[data-group-id="check:${topicId}"]`, // Matching "check:XXXX" box of the topic `[data-group-id="check:${topicId}"]`, // Matching "check:XXXX" box of the topic
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes `[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
`[data-id="${topicId}"]`, // Matching custom roadmap nodes
], ],
parentElement parentElement
).forEach((element) => { ).forEach((element) => {
@ -257,6 +258,8 @@ export function clearResourceProgress() {
'.clickable-group', '.clickable-group',
'[data-type="topic"]', '[data-type="topic"]',
'[data-type="subtopic"]', '[data-type="subtopic"]',
'.react-flow__node-topic',
'.react-flow__node-subtopic',
]); ]);
for (const clickableElement of matchingElements) { for (const clickableElement of matchingElements) {
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed'); clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
@ -290,7 +293,7 @@ export async function renderResourceProgress(
function getMatchingElements( function getMatchingElements(
quries: string[], quries: string[],
parentElement: Document | SVGElement = document parentElement: Document | SVGElement | HTMLDivElement = document
): Element[] { ): Element[] {
const matchingElements: Element[] = []; const matchingElements: Element[] = [];
quries.forEach((query) => { quries.forEach((query) => {
@ -314,6 +317,8 @@ export function refreshProgressCounters() {
'.clickable-group', '.clickable-group',
'[data-type="topic"]', '[data-type="topic"]',
'[data-type="subtopic"]', '[data-type="subtopic"]',
'.react-flow__node-topic',
'.react-flow__node-subtopic',
]).length; ]).length;
const externalLinks = document.querySelectorAll( const externalLinks = document.querySelectorAll(
@ -350,15 +355,20 @@ export function refreshProgressCounters() {
getMatchingElements([ getMatchingElements([
'.clickable-group.done:not([data-group-id^="ext_link:"])', '.clickable-group.done:not([data-group-id^="ext_link:"])',
'[data-node-id].done', // All data-node-id=*.done elements are custom roadmap nodes '[data-node-id].done', // All data-node-id=*.done elements are custom roadmap nodes
'[data-id].done', // All data-id=*.done elements are custom roadmap nodes
]).length - totalCheckBoxesDone; ]).length - totalCheckBoxesDone;
const totalLearning = const totalLearning =
getMatchingElements([ getMatchingElements([
'.clickable-group.learning', '.clickable-group.learning',
'[data-node-id].learning', '[data-node-id].learning',
'[data-id].learning',
]).length - totalCheckBoxesLearning; ]).length - totalCheckBoxesLearning;
const totalSkipped = const totalSkipped =
getMatchingElements(['.clickable-group.skipped', '[data-node-id].skipped']) getMatchingElements([
.length - totalCheckBoxesSkipped; '.clickable-group.skipped',
'[data-node-id].skipped',
'[data-id].skipped',
]).length - totalCheckBoxesSkipped;
const doneCountEls = document.querySelectorAll('[data-progress-done]'); const doneCountEls = document.querySelectorAll('[data-progress-done]');
if (doneCountEls.length > 0) { if (doneCountEls.length > 0) {

@ -1,6 +1,9 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue,svg}'], 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}'
],
future: { future: {
hoverOnlyWhenSupported: true, hoverOnlyWhenSupported: true,
}, },

Loading…
Cancel
Save