mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-16 14:14:11 +02:00
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>
This commit is contained in:
5
.gitignore
vendored
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',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
14
editor/readonly-editor.tsx
Normal file
14
editor/readonly-editor.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
62
package.json
62
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2880
pnpm-lock.yaml
generated
2880
pnpm-lock.yaml
generated
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>
|
||||||
)}
|
)}
|
||||||
|
158
src/components/CustomRoadmap/FlowRoadmapRenderer.tsx
Normal file
158
src/components/CustomRoadmap/FlowRoadmapRenderer.tsx
Normal file
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
in
|
from
|
||||||
<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!
|
||||||
|
294
src/components/TeamProgress/MemberCustomProgressModal.tsx
Normal file
294
src/components/TeamProgress/MemberCustomProgressModal.tsx
Normal file
@@ -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'}
|
||||||
|
148
src/components/TeamProgress/MemberProgressModalHeader.tsx
Normal file
148
src/components/TeamProgress/MemberProgressModalHeader.tsx
Normal file
@@ -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() +
|
||||||
|
37
src/components/UserProgress/ProgressLoadingError.tsx
Normal file
37
src/components/UserProgress/ProgressLoadingError.tsx
Normal file
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
218
src/components/UserProgress/UserCustomProgressModal.tsx
Normal file
218
src/components/UserProgress/UserCustomProgressModal.tsx
Normal file
@@ -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}
|
||||||
|
79
src/components/UserProgress/UserProgressModalHeader.tsx
Normal file
79
src/components/UserProgress/UserProgressModalHeader.tsx
Normal file
@@ -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
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,
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user