mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-01-16 13:51:23 +01: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:
parent
d46cf26812
commit
3a0e588530
5
.gitignore
vendored
5
.gitignore
vendored
@ -29,6 +29,5 @@ pnpm-debug.log*
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
/renderer/*
|
||||
!/renderer/index.tsx
|
||||
!/renderer/renderer.ts
|
||||
/editor/*
|
||||
!/editor/readonly-editor.tsx
|
@ -13,6 +13,6 @@ module.exports = {
|
||||
],
|
||||
plugins: [
|
||||
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-dirs": "node scripts/roadmap-dirs.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-content": "node scripts/best-practice-content.cjs",
|
||||
"generate-renderer": "sh scripts/generate-renderer.sh",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^3.0.0",
|
||||
"@astrojs/sitemap": "^1.3.3",
|
||||
"@astrojs/tailwind": "^5.0.0",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@astrojs/react": "^3.0.3",
|
||||
"@astrojs/sitemap": "^3.0.2",
|
||||
"@astrojs/tailwind": "^5.0.2",
|
||||
"@fingerprintjs/fingerprintjs": "^4.1.0",
|
||||
"@nanostores/react": "^0.7.1",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"astro": "^3.0.5",
|
||||
"astro-compress": "^2.0.8",
|
||||
"@types/react": "^18.2.29",
|
||||
"@types/react-dom": "^18.2.14",
|
||||
"astro": "^3.3.2",
|
||||
"astro-compress": "^2.0.15",
|
||||
"clsx": "^2.0.0",
|
||||
"dracula-prism": "^2.1.13",
|
||||
"jose": "^4.14.4",
|
||||
"jose": "^4.15.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.274.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"nanostores": "^0.9.2",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"lucide-react": "^0.288.0",
|
||||
"nanoid": "^5.0.2",
|
||||
"nanostores": "^0.9.4",
|
||||
"node-html-parser": "^6.1.10",
|
||||
"npm-check-updates": "^16.14.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"reactflow": "^11.8.3",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"reactflow": "^11.9.4",
|
||||
"@roadmapsh/web-draw": "git+https://github.com/roadmapsh/web-draw.git",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.3.3"
|
||||
"tailwindcss": "^3.3.3",
|
||||
"zustand": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/js-cookie": "^3.0.5",
|
||||
"@types/prismjs": "^1.26.2",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^5.0.0",
|
||||
"gh-pages": "^6.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.3.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-astro": "^0.10.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
"markdown-it": "^13.0.2",
|
||||
"openai": "^4.12.4",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-astro": "^0.12.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
|
||||
|
||||
@ -8,17 +8,17 @@ if [ ! -d ".temp/web-draw" ]; then
|
||||
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
|
||||
fi
|
||||
|
||||
rm -rf renderer
|
||||
mkdir renderer
|
||||
rm -rf editor
|
||||
mkdir editor
|
||||
|
||||
# copy the files at /src/editor/renderer/* to /renderer
|
||||
# copy the files at /src/editor/* to /editor
|
||||
# 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
|
||||
# so that the typescript compiler doesn't complain
|
||||
# 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
|
||||
echo "// @ts-nocheck" > temp
|
||||
cat "$file" >> temp
|
||||
@ -28,6 +28,5 @@ find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= r
|
||||
done
|
||||
|
||||
|
||||
|
||||
# ignore the worktree changes for the renderer directory
|
||||
git update-index --skip-worktree renderer/*
|
||||
# ignore the worktree changes for the editor directory
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx
|
@ -167,8 +167,7 @@ const sidebarLinks = [
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
{sidebarLink.isNew &&
|
||||
!isActive && (
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<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' />
|
||||
|
@ -21,7 +21,7 @@ export function EmailLoginForm() {
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Log the user in and reload the page
|
||||
@ -39,7 +39,7 @@ export function EmailLoginForm() {
|
||||
// @todo use proper types
|
||||
if ((error as any).type === 'user_not_verified') {
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
email,
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
|
||||
<button
|
||||
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',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={toggleCreateRoadmapHandler}
|
||||
>
|
||||
|
@ -62,7 +62,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
|
||||
async function handleSubmit(
|
||||
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
|
||||
redirect: boolean = true
|
||||
redirect: boolean = true,
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (isLoading) {
|
||||
@ -85,7 +85,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
}),
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@ -96,9 +96,9 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
|
||||
toast.success('Roadmap created successfully');
|
||||
if (redirect) {
|
||||
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
|
||||
response?._id
|
||||
}`;
|
||||
window.location.href = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${response?._id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
type="button"
|
||||
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',
|
||||
!teamId && 'w-full'
|
||||
!teamId && 'w-full',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
@ -213,7 +213,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
type="submit"
|
||||
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',
|
||||
teamId ? 'hidden sm:flex' : 'w-full'
|
||||
teamId ? 'hidden sm:flex' : 'w-full',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
@ -7,13 +7,12 @@ import {
|
||||
httpPost,
|
||||
} from '../../lib/http';
|
||||
import { RoadmapHeader } from './RoadmapHeader';
|
||||
import { RoadmapRenderer } from './RoadmapRenderer';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { currentRoadmap } from '../../stores/roadmap';
|
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { RestrictedPage } from './RestrictedPage';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { FlowRoadmapRenderer } from './FlowRoadmapRenderer';
|
||||
|
||||
export const allowedLinkTypes = [
|
||||
'video',
|
||||
@ -121,13 +120,8 @@ export function CustomRoadmap() {
|
||||
return (
|
||||
<>
|
||||
<RoadmapHeader />
|
||||
<RoadmapRenderer roadmap={roadmap!} />
|
||||
<FlowRoadmapRenderer roadmap={roadmap!} />
|
||||
<TopicDetail canSubmitContribution={false} />
|
||||
<UserProgressModal
|
||||
resourceId={roadmap?._id!}
|
||||
resourceType="roadmap"
|
||||
isCustomResource={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import { CircleSlash, PenSquare, Shapes } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type EmptyRoadmapProps = {
|
||||
roadmapId: string;
|
||||
canManage: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyRoadmap(props: EmptyRoadmapProps) {
|
||||
const { roadmapId, canManage } = props;
|
||||
const { roadmapId, canManage, className } = props;
|
||||
const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`;
|
||||
|
||||
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">
|
||||
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
|
||||
<h3 className="mt-2">This roadmap is currently empty.</h3>
|
||||
@ -18,9 +20,9 @@ export function EmptyRoadmap(props: EmptyRoadmapProps) {
|
||||
{canManage && (
|
||||
<a
|
||||
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
|
||||
</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
|
||||
data-progress-nums-container=""
|
||||
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-md': !isSecondaryBanner,
|
||||
|
@ -102,7 +102,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
</span>
|
||||
{team && (
|
||||
<>
|
||||
in
|
||||
from
|
||||
<span className="font-semibold text-gray-900">
|
||||
{team?.name}
|
||||
</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 { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { InviteFriendPopup } from './InviteFriendPopup';
|
||||
import { UserCustomProgressModal } from '../UserProgress/UserCustomProgressModal';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
@ -107,6 +108,25 @@ export function FriendsPage() {
|
||||
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 (
|
||||
<div>
|
||||
{showInviteFriendPopup && (
|
||||
@ -116,15 +136,7 @@ export function FriendsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFriendProgress && (
|
||||
<UserProgressModal
|
||||
userId={showFriendProgress.friend.userId}
|
||||
resourceId={showFriendProgress.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => setShowFriendProgress(undefined)}
|
||||
isCustomResource={showFriendProgress.isCustomResource}
|
||||
/>
|
||||
)}
|
||||
{showFriendProgress && progressModal}
|
||||
|
||||
<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">
|
||||
|
@ -23,7 +23,7 @@ function ProgressStatButton(props: ProgressStatButtonProps) {
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
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}
|
||||
<span className="flex flex-grow justify-between">
|
||||
@ -31,7 +31,7 @@ function ProgressStatButton(props: ProgressStatButtonProps) {
|
||||
<span>{count}</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
|
||||
</span>
|
||||
</button>
|
||||
@ -62,7 +62,7 @@ export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
<span className="inline sm:hidden">questions</span>
|
||||
</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
|
||||
icon={<ThumbsUp className="mr-1 h-4" />}
|
||||
label="Knew"
|
||||
@ -85,10 +85,10 @@ export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
onClick={() => onReset('skip')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 mb-4 sm:mb-0 text-sm">
|
||||
<div className="mb-4 mt-2 text-sm sm:mb-0">
|
||||
<button
|
||||
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" />
|
||||
Restart Asking
|
||||
|
@ -46,7 +46,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
const { response, error } = await httpGet<UserQuestionProgress>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-user-question-progress/${groupId}`
|
||||
}/v1-get-user-question-progress/${groupId}`,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@ -106,7 +106,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
}/v1-reset-question-progress/${groupId}`,
|
||||
{
|
||||
status: type,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@ -139,7 +139,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
|
||||
async function updateQuestionStatus(
|
||||
status: QuestionProgressType,
|
||||
questionId: string
|
||||
questionId: string,
|
||||
) {
|
||||
setIsLoading(true);
|
||||
let newProgress = userProgress || { know: [], dontKnow: [], skip: [] };
|
||||
@ -161,7 +161,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
status,
|
||||
questionId,
|
||||
questionGroupId: groupId,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@ -173,7 +173,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
}
|
||||
|
||||
const updatedQuestionList = pendingQuestions.filter(
|
||||
(q) => q.id !== questionId
|
||||
(q) => q.id !== questionId,
|
||||
);
|
||||
|
||||
setUserProgress(newProgress);
|
||||
@ -198,7 +198,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
const hasFinished = !isLoading && hasProgress && !currQuestion;
|
||||
|
||||
return (
|
||||
<div className="mb-0 sm:mb-40 gap-3 text-center">
|
||||
<div className="mb-0 gap-3 text-center sm:mb-40">
|
||||
<QuestionsProgress
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
@ -241,7 +241,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
</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'
|
||||
}`}
|
||||
>
|
||||
@ -249,10 +249,10 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
disabled={isLoading || !currQuestion}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
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" />
|
||||
Already Know that
|
||||
@ -260,11 +260,11 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
<button
|
||||
onClick={() => {
|
||||
updateQuestionStatus('dontKnow', currQuestion.id).finally(
|
||||
() => null
|
||||
() => null,
|
||||
);
|
||||
}}
|
||||
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" />
|
||||
Didn't Know that
|
||||
@ -275,7 +275,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
}}
|
||||
disabled={isLoading || !currQuestion}
|
||||
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" />
|
||||
Skip Question
|
||||
|
@ -26,7 +26,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
const donePercentage = (totalSolved / totalCount) * 100;
|
||||
|
||||
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="relative w-full flex-1 rounded-xl bg-gray-200 p-1">
|
||||
<div
|
||||
@ -79,12 +79,12 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4" />
|
||||
Reset
|
||||
<span className='inline lg:hidden'>Progress</span>
|
||||
<span className="inline lg:hidden">Progress</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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{' '}
|
||||
<button
|
||||
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>
|
||||
)}
|
||||
<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'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
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" />
|
||||
{question}
|
||||
</span>
|
||||
@ -61,7 +61,7 @@ export function RoadmapTitleQuestion(props: RoadmapTitleQuestionProps) {
|
||||
</h2>
|
||||
)}
|
||||
<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) }}
|
||||
></div>
|
||||
</div>
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UserItem } from './UserItem';
|
||||
import { Users2 } from 'lucide-react';
|
||||
import {httpGet} from "../../lib/http";
|
||||
import { Check, Copy, Group, UserPlus2, Users2 } from 'lucide-react';
|
||||
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 =
|
||||
| 'none'
|
||||
@ -41,10 +44,13 @@ type ShareFriendListProps = {
|
||||
};
|
||||
|
||||
export function ShareFriendList(props: ShareFriendListProps) {
|
||||
const userId = getUser()?.id!;
|
||||
const { setFriends, friends, sharedFriendIds, setSharedFriendIds } = props;
|
||||
const toast = useToast();
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isAddingFriend, setIsAddingFriend] = useState(false);
|
||||
|
||||
async function loadFriends() {
|
||||
if (friends.length > 0) {
|
||||
@ -53,7 +59,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
|
||||
setIsLoading(true);
|
||||
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) {
|
||||
@ -87,6 +93,10 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
const baseWebUrl = isDev ? 'http://localhost:3000' : 'https://roadmap.sh';
|
||||
const befriendUrl = `${baseWebUrl}/befriend?u=${userId}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(friends.length > 0 || isLoading) && (
|
||||
@ -112,6 +122,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
|
||||
{loadingFriends}
|
||||
{friends.length > 0 && !isLoading && (
|
||||
<>
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{friends.map((friend) => {
|
||||
const isSelected = sharedFriendIds?.includes(friend.userId);
|
||||
@ -127,7 +138,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSharedFriendIds(
|
||||
sharedFriendIds.filter((id) => id !== friend.userId)
|
||||
sharedFriendIds.filter((id) => id !== friend.userId),
|
||||
);
|
||||
} else {
|
||||
setSharedFriendIds([...sharedFriendIds, friend.userId]);
|
||||
@ -138,6 +149,58 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
);
|
||||
})}
|
||||
</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 && (
|
||||
@ -148,7 +211,7 @@ export function ShareFriendList(props: ShareFriendListProps) {
|
||||
<a
|
||||
target="_blank"
|
||||
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.
|
||||
</a>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { CheckCircle, CheckCircle2, CheckIcon } from 'lucide-react';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
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 { cn } from '../../lib/classname.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
@ -76,7 +76,7 @@ export function TeamPricing() {
|
||||
copyText(teamEmail);
|
||||
}}
|
||||
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}
|
||||
@ -91,7 +91,7 @@ export function TeamPricing() {
|
||||
{
|
||||
'top-full': !isCopied,
|
||||
'top-0': isCopied,
|
||||
}
|
||||
},
|
||||
)}
|
||||
>
|
||||
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 { useAuth } from '../../hooks/use-auth';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
import { renderFlowJSON } from '../../../renderer/renderer';
|
||||
import {
|
||||
allowedClickableNodeTypes,
|
||||
getNodeDetails,
|
||||
} from '../CustomRoadmap/RoadmapRenderer';
|
||||
import { MemberProgressModalHeader } from './MemberProgressModalHeader';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
member: TeamMember;
|
||||
@ -49,7 +43,6 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
onShowMyProgress,
|
||||
teamId,
|
||||
onClose,
|
||||
isCustomResource,
|
||||
} = props;
|
||||
const user = useAuth();
|
||||
const isCurrentUser = user?.email === member.email;
|
||||
@ -70,12 +63,6 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
if (isCustomResource) {
|
||||
resourceJsonUrl = `${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-roadmap/${resourceId}`;
|
||||
}
|
||||
|
||||
async function getMemberProgress(
|
||||
teamId: string,
|
||||
memberId: string,
|
||||
@ -98,28 +85,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const res = await fetch(jsonUrl, {
|
||||
...(isCustomResource && {
|
||||
credentials: 'include',
|
||||
}),
|
||||
});
|
||||
const res = await fetch(jsonUrl, {});
|
||||
const json = await res.json();
|
||||
let svg: SVGElement | null = null;
|
||||
if (isCustomResource) {
|
||||
svg = await renderFlowJSON(
|
||||
{
|
||||
nodes: json.nodes,
|
||||
edges: json.edges,
|
||||
},
|
||||
{
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
svg = await wireframeJSONToSVG(json, {
|
||||
const svg: SVGElement | null = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
}
|
||||
|
||||
containerEl.current?.replaceChildren(svg);
|
||||
}
|
||||
@ -215,29 +185,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
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 : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
topicId = groupId.replace(/^\d+-/, '');
|
||||
}
|
||||
const topicId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
e.preventDefault();
|
||||
@ -255,29 +207,11 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
if (!targetGroup) {
|
||||
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 : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
topicId = groupId.replace(/^\d+-/, '');
|
||||
}
|
||||
const topicId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
@ -321,136 +255,24 @@ export function MemberProgressModal(props: ProgressMapProps) {
|
||||
};
|
||||
}, [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 (
|
||||
<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={isCustomResource ? 'original-roadmap' : 'customized-roadmap'}
|
||||
id={'customized-roadmap'}
|
||||
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"
|
||||
>
|
||||
{isCurrentUser && (
|
||||
<div className="sticky top-1 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>
|
||||
<MemberProgressModalHeader
|
||||
resourceId={resourceId}
|
||||
member={member}
|
||||
progress={memberProgress}
|
||||
isCurrentUser={isCurrentUser}
|
||||
onShowMyProgress={onShowMyProgress}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<div
|
||||
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 { useAuth } from '../../hooks/use-auth';
|
||||
import { MemberProgressModal } from './MemberProgressModal';
|
||||
import { MemberCustomProgressModal } from './MemberCustomProgressModal';
|
||||
|
||||
export type UserProgress = {
|
||||
resourceTitle: string;
|
||||
@ -152,10 +153,15 @@ export function TeamProgressPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ProgressModal =
|
||||
showMemberProgress && !showMemberProgress.isCustomResource
|
||||
? MemberProgressModal
|
||||
: MemberCustomProgressModal;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showMemberProgress && (
|
||||
<MemberProgressModal
|
||||
<ProgressModal
|
||||
member={showMemberProgress.member}
|
||||
teamId={teamId}
|
||||
resourceId={showMemberProgress.resourceId}
|
||||
|
@ -8,13 +8,13 @@ import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useToggleTopic } from '../../hooks/use-toggle-topic';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import {
|
||||
isTopicDone,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress as updateResourceProgressApi,
|
||||
} from '../../lib/resource-progress';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { pageProgressMessage, sponsorHidden } from '../../stores/page';
|
||||
import { TopicProgressButton } from './TopicProgressButton';
|
||||
import { ContributionForm } from './ContributionForm';
|
||||
@ -95,13 +95,13 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
oldIsDone ? 'pending' : 'done'
|
||||
)
|
||||
oldIsDone ? 'pending' : 'done',
|
||||
),
|
||||
)
|
||||
.then(({ done = [] }) => {
|
||||
renderTopicProgress(
|
||||
topicId,
|
||||
done.includes(topicId) ? 'done' : 'pending'
|
||||
done.includes(topicId) ? 'done' : 'pending',
|
||||
);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
@ -149,7 +149,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
Accept: 'text/html',
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
.then(({ response }) => {
|
||||
if (!response) {
|
||||
@ -163,7 +163,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(
|
||||
response as string,
|
||||
'text/html'
|
||||
'text/html',
|
||||
);
|
||||
topicHtml = node?.getElementById('main-content')?.outerHTML || '';
|
||||
} else {
|
||||
@ -171,7 +171,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
setTopicTitle((response as RoadmapContentDocument)?.title || '');
|
||||
topicHtml = markdownToHtml(
|
||||
(response as RoadmapContentDocument)?.description || '',
|
||||
false
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@ -279,7 +279,7 @@ export function TopicDetail(props: TopicDetailProps) {
|
||||
<span
|
||||
className={cn(
|
||||
'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() +
|
||||
|
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 { deleteUrlParam, getUrlParams } from '../../lib/browser';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { ErrorIcon } from '../ReactIcons/ErrorIcon';
|
||||
import { renderFlowJSON } from '../../../renderer/renderer';
|
||||
import { ProgressLoadingError } from './ProgressLoadingError';
|
||||
import { UserProgressModalHeader } from './UserProgressModalHeader';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
userId?: string;
|
||||
@ -21,7 +20,7 @@ export type ProgressMapProps = {
|
||||
isCustomResource?: boolean;
|
||||
};
|
||||
|
||||
type UserProgressResponse = {
|
||||
export type UserProgressResponse = {
|
||||
user: {
|
||||
_id: string;
|
||||
name: string;
|
||||
@ -40,7 +39,6 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceType,
|
||||
userId: propUserId,
|
||||
onClose: onModalClose,
|
||||
isCustomResource,
|
||||
} = props;
|
||||
|
||||
const { s: userId = propUserId } = getUrlParams();
|
||||
@ -69,12 +67,6 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
if (isCustomResource) {
|
||||
resourceJsonUrl = `${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-roadmap/${resourceId}`;
|
||||
}
|
||||
|
||||
async function getUserProgress(
|
||||
userId: string,
|
||||
resourceType: string,
|
||||
@ -101,12 +93,6 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
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, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
@ -180,14 +166,6 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
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);
|
||||
setProgressResponse(user);
|
||||
})
|
||||
@ -199,16 +177,6 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
});
|
||||
}, [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) {
|
||||
deleteUrlParam('s');
|
||||
return null;
|
||||
@ -219,31 +187,7 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
}
|
||||
|
||||
if (isLoading || error) {
|
||||
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>
|
||||
);
|
||||
return <ProgressLoadingError isLoading={isLoading} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -256,62 +200,10 @@ export function UserProgressModal(props: ProgressMapProps) {
|
||||
ref={popupBodyEl}
|
||||
className={`popup-body relative rounded-lg bg-white pt-[1px] shadow`}
|
||||
>
|
||||
<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>
|
||||
<UserProgressModalHeader
|
||||
isLoading={isLoading}
|
||||
progressResponse={progressResponse}
|
||||
/>
|
||||
|
||||
<div
|
||||
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" />
|
||||
import 'astro/client';
|
||||
|
||||
interface ImportMetaEnv {
|
||||
GITHUB_SHA: string;
|
||||
|
@ -191,7 +191,7 @@ export function setResourceProgress(
|
||||
|
||||
export function topicSelectorAll(
|
||||
topicId: string,
|
||||
parentElement: Document | SVGElement = document
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document
|
||||
): 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="check:${topicId}"]`, // Matching "check:XXXX" box of the topic
|
||||
`[data-node-id="${topicId}"]`, // Matching custom roadmap nodes
|
||||
`[data-id="${topicId}"]`, // Matching custom roadmap nodes
|
||||
],
|
||||
parentElement
|
||||
).forEach((element) => {
|
||||
@ -257,6 +258,8 @@ export function clearResourceProgress() {
|
||||
'.clickable-group',
|
||||
'[data-type="topic"]',
|
||||
'[data-type="subtopic"]',
|
||||
'.react-flow__node-topic',
|
||||
'.react-flow__node-subtopic',
|
||||
]);
|
||||
for (const clickableElement of matchingElements) {
|
||||
clickableElement.classList.remove('done', 'skipped', 'learning', 'removed');
|
||||
@ -290,7 +293,7 @@ export async function renderResourceProgress(
|
||||
|
||||
function getMatchingElements(
|
||||
quries: string[],
|
||||
parentElement: Document | SVGElement = document
|
||||
parentElement: Document | SVGElement | HTMLDivElement = document
|
||||
): Element[] {
|
||||
const matchingElements: Element[] = [];
|
||||
quries.forEach((query) => {
|
||||
@ -314,6 +317,8 @@ export function refreshProgressCounters() {
|
||||
'.clickable-group',
|
||||
'[data-type="topic"]',
|
||||
'[data-type="subtopic"]',
|
||||
'.react-flow__node-topic',
|
||||
'.react-flow__node-subtopic',
|
||||
]).length;
|
||||
|
||||
const externalLinks = document.querySelectorAll(
|
||||
@ -350,15 +355,20 @@ export function refreshProgressCounters() {
|
||||
getMatchingElements([
|
||||
'.clickable-group.done:not([data-group-id^="ext_link:"])',
|
||||
'[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;
|
||||
const totalLearning =
|
||||
getMatchingElements([
|
||||
'.clickable-group.learning',
|
||||
'[data-node-id].learning',
|
||||
'[data-id].learning',
|
||||
]).length - totalCheckBoxesLearning;
|
||||
const totalSkipped =
|
||||
getMatchingElements(['.clickable-group.skipped', '[data-node-id].skipped'])
|
||||
.length - totalCheckBoxesSkipped;
|
||||
getMatchingElements([
|
||||
'.clickable-group.skipped',
|
||||
'[data-node-id].skipped',
|
||||
'[data-id].skipped',
|
||||
]).length - totalCheckBoxesSkipped;
|
||||
|
||||
const doneCountEls = document.querySelectorAll('[data-progress-done]');
|
||||
if (doneCountEls.length > 0) {
|
||||
|
@ -1,6 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
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: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user