Compare commits

...

69 Commits

Author SHA1 Message Date
Arik Chakma
d3e1324b31 Add DevOps forkable 2023-10-30 09:14:42 +06:00
Kamran Ahmed
980e243124 Fix issue with chrome v83 2023-10-29 16:54:54 +00:00
Arik Chakma
044046e044 Add forkable Backend Roadmap (#4635)
* Add forkable Backend roadmap

* Add `(Fork)` at title
2023-10-28 13:02:32 +01:00
Kamran Ahmed
793764c3a3 Fix URL for http caching 2023-10-27 14:41:19 +01:00
Kamran Ahmed
abc8a97676 Update twitter link 2023-10-27 01:57:53 +01:00
Kamran Ahmed
79355cd876 Update meta titles 2023-10-26 22:59:18 +01:00
Kamran Ahmed
2809b81920 Add game developer roadmap 2023-10-26 22:54:46 +01:00
Kamran Ahmed
204a9577cd Add content for game developer roadmap 2023-10-26 20:34:04 +01:00
Kamran Ahmed
577e724aa7 Add game developer roadmap 2023-10-26 19:53:45 +01:00
Abdelrhman Kamal
14a1544ed4 Feat auto-focused side panel (#4631)
* Fix gtx-trans close sidepanel

* reset the package-lock.json file

* Feat: Add auto focus to side panels

* resote changes
2023-10-25 19:10:59 +01:00
Kamran Ahmed
14ea7ba0ad Open roadmap editor in same window 2023-10-25 16:32:37 +01:00
Kamran Ahmed
5e7ec4f8d8 Add scalability article 2023-10-25 16:06:50 +01:00
Sherkhan Azimov
417badc6ea fix: broken link to scalability in system design (#4616) 2023-10-25 16:05:49 +01:00
Arik Chakma
0558957673 Allow creating personal version of frontend roadmap (#4627)
* Create Roadmap Version

* Change button position

* Update frontend JSON

* Remove `topicCount`

* Add fork at title

* Update UI for create your own version

* Add functionality to load your own version

* Load user version of roadmap

* Update forkable roadmap

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-10-25 12:51:05 +01:00
Abdelrhman Kamal
7f6a42a0c5 Clarify Usage of MongoDB's $currentDate operator (#4630)
* Fix gtx-trans close sidepanel

* reset the package-lock.json file

* Fix: mongoDB date type
2023-10-25 09:47:15 +01:00
Abdelrhman Kamal
cc258b7612 Fix mongodb optimization section (#4629)
* Fix gtx-trans close sidepanel

* reset the package-lock.json file

* Fix: Performance Optimization

* Restore src/components/TopicDetail/TopicProgressButton.tsx file
2023-10-25 09:42:35 +01:00
Kamran Ahmed
7da244fe10 Add related questions below roadmaps 2023-10-24 23:48:50 +01:00
Kamran Ahmed
cf78628c0c Add content for android 2023-10-24 21:01:55 +01:00
Kamran Ahmed
498e03720f Create files for android roadmap 2023-10-24 20:57:54 +01:00
Kamran Ahmed
5c69b05470 Update android roadmap 2023-10-24 20:49:59 +01:00
Abdelrhman Kamal
309cf3d6d9 Fix: google translate extenstion close side panel (#4625)
* Fix gtx-trans close sidepanel

* reset the package-lock.json file
2023-10-24 14:19:53 +01:00
Kamran Ahmed
4f3b891e45 Update dependencies 2023-10-24 14:16:26 +01:00
Kamran Ahmed
47f548a0e4 Update dependencies 2023-10-24 14:07:41 +01:00
Kamran Ahmed
a988ecc4ab Roadmap action button color 2023-10-24 14:03:36 +01:00
Kamran Ahmed
c723070057 Remove web-draw package 2023-10-23 16:57:58 +01:00
Kamran Ahmed
3a0e588530 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>
2023-10-21 19:42:55 +01:00
Arik Chakma
d46cf26812 Minor Improvement for Custom Roadmap (#4590)
* Add Edit button in the roadmap list

* Add share with others button

* Fix editor link
2023-10-21 19:40:26 +01:00
Kamran Ahmed
b06e82de5f Sponsor for nginx 2023-10-13 22:41:46 +01:00
Kamran Ahmed
d65ecac777 Account dropdown changes 2023-10-13 19:52:21 +01:00
Kamran Ahmed
c46d962803 Add links to questions 2023-10-12 21:22:01 +01:00
Kamran Ahmed
bd4e7ea3d0 Add links to questions 2023-10-12 21:20:21 +01:00
Kamran Ahmed
252b083a48 add roadmap editor image 2023-10-12 20:56:01 +01:00
Arik Chakma
abbeb717d1 Add JavaScript questions (#4505)
* Add Javascript questions

* wip: add more questions

* wip: add ternary operator

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* fix: set example

* wip: add more questions

* wip: add more question

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add another question

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions

* wip: add more questions
2023-10-12 15:03:19 +01:00
Kamran Ahmed
485ca9dd8f Spring testing link fix 2023-10-11 14:32:40 +01:00
Kamran Ahmed
c3315fb41e Fix typo on teams page 2023-10-11 12:56:56 +01:00
Kamran Ahmed
6ed436674f Discovery page option in sharing 2023-10-10 00:12:05 +01:00
Kamran Ahmed
76c6c4dc1f isDiscoverable not persisted 2023-10-10 00:06:24 +01:00
Kamran Ahmed
cb56e85651 Discoverable option selection 2023-10-09 23:27:49 +01:00
Kamran Ahmed
dcf740e275 Update share buttons text 2023-10-09 21:57:33 +01:00
Arik Chakma
16662ed699 Implement Social Share options (#4569)
* Implement social share options

* Minor fix
2023-10-09 21:49:21 +01:00
Kamran Ahmed
6f9fe361ae Change style of custom roadmap page 2023-10-09 09:07:59 +01:00
Arik Chakma
036b34c6f3 Implement Custom Roadmap minor features (#4565)
* Remove roadmap type

* Add Edit Roadmap button

* Add Edit Roadmap permission

* Add Edit and Share roadmap button

* Remove Margin

* Implement Discoverable Checkbox

* Add Loading State for buttons
2023-10-09 08:44:30 +01:00
Kamran Ahmed
93c2043f23 Fix warning in hero roadmap 2023-10-08 18:38:02 +01:00
Saleh Hashemi
d2da3c8621 update checkout version to v4 (#4559) 2023-10-07 22:15:01 +01:00
Kamran Ahmed
4aa8f15c07 Add email icon in footer 2023-10-07 15:31:24 +01:00
Arik Chakma
ceb4c3b95d Remove invited members from sharing settings (#4555)
* Fix team member list

* Minor change
2023-10-06 19:00:48 +01:00
Kamran Ahmed
7ec5e30b51 Hero roadmap section updates 2023-10-06 19:00:08 +01:00
Kamran Ahmed
e5e0a7c8c5 Add teams banner 2023-10-04 20:32:28 +01:00
Kamran Ahmed
90f3ffe270 Add banner for teams 2023-10-04 16:13:41 +01:00
Kamran Ahmed
ce47a7433e Teams button in navigation 2023-10-04 15:44:34 +01:00
Selva Muthu Kumaran
21b8358683 roadmap-aspnet-change-tracker-api.md (#4546)
aspnet-change-tracker-api URL fixed
fixes : #4544
2023-10-04 20:40:45 +06:00
Kamran Ahmed
e1751b105f Add team page 2023-10-04 15:28:46 +01:00
Kamran Ahmed
e43bea7c40 Setup redirects on the teams page 2023-10-04 15:22:20 +01:00
Kamran Ahmed
5fa669aec2 Update team page 2023-10-04 15:06:59 +01:00
Kamran Ahmed
4b8f868b2b Add roadmaps and friends to account dropdown 2023-10-04 10:34:29 +01:00
Kamran Ahmed
a0743a8272 Fix sharing options button 2023-10-04 10:30:28 +01:00
Arik Chakma
2cae13c090 Add Members while Transferring Roadmap (#4534)
* Add members while Transferring Roadmap

* Implement Responsive in Roadmaps page
2023-10-04 10:15:56 +01:00
Kamran Ahmed
0bf287f1d6 Add features to pricing 2023-10-04 10:12:08 +01:00
Kamran Ahmed
d7d819b4b3 Add teams introduction page 2023-10-03 21:07:53 +01:00
Kamran Ahmed
29cff6a6f8 Update badge 2023-10-02 17:34:51 +01:00
Kamran Ahmed
044df81b7a Creator details on roadmap page 2023-10-02 17:03:20 +01:00
Arik Chakma
3151ee5021 Add Creator Details (#4530)
* Add Creator details

* Add Skeleton Loading
2023-10-02 16:37:30 +01:00
Kamran Ahmed
e6ce9f40ee Update roadmap contribution template 2023-10-02 15:50:42 +01:00
Kamran Ahmed
3b5e3c44f9 Update label for roadmap creation 2023-10-02 15:10:48 +01:00
Kamran Ahmed
c286e0a6f8 Increase max team member count 2023-10-01 03:24:16 +01:00
linxiaowang
3bebe0c1de fix(typo): fix typo in 101-instanceof-operator.md (#4514) 2023-10-01 03:00:55 +06:00
Sherkhan Azimov
9845fe624a separate articles in 107-domain-name-system.md (#4517)
Transfer an article to a new line
2023-10-01 02:59:02 +06:00
Nicky Lim
4b2b2ebe8c Fix typo cpp 104 index (#4520) 2023-09-30 21:40:03 +06:00
Arik Chakma
82c2aaacc3 Fix Roadmap Share Link (#4522) 2023-09-30 14:48:04 +01:00
451 changed files with 42947 additions and 2100 deletions

View File

@@ -14,24 +14,12 @@ body:
placeholder: e.g. Roadmap to learn Data Science
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: Is this roadmap prepared by you or someone else?
options:
- I prepared this roadmap
- I found this roadmap online (please provide a link below)
- type: textarea
id: roadmap-description
attributes:
label: Roadmap Items
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
label: Roadmap Link
description: Please create the roadmap [using our roadmap editor](https://twitter.com/kamrify/status/1708293162693767426) and submit the roadmap link.
placeholder: |
- Item 1
- Subitem 1
- Subitem 2
- Item 2
- Subitem 1
- Subitem 2
https://roadmap.sh/xyz
validations:
required: true
required: true

View File

@@ -12,7 +12,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v1

View File

@@ -9,7 +9,7 @@ jobs:
upgrade-deps:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 18

5
.gitignore vendored
View File

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

3
.npmrc
View File

@@ -1 +1,2 @@
auto-install-peers=true
auto-install-peers=true
strict-peer-dependencies=false

View File

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

View 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>
);
}

2
package-lock.json generated
View File

@@ -11025,4 +11025,4 @@
}
}
}
}
}

View File

@@ -16,53 +16,54 @@
"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.31",
"@types/react-dom": "^18.2.14",
"astro": "^3.3.3",
"astro-compress": "^2.1.5",
"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",
"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.4"
},
"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.13.0",
"prettier": "^3.0.3",
"prettier-plugin-astro": "^0.12.0",
"prettier-plugin-tailwindcss": "^0.5.6"
}
}

2305
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

View File

@@ -9,8 +9,8 @@
<a href="https://roadmap.sh/best-practices">
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
</a>
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
<a href="https://roadmap.sh/questions">
<img src="https://img.shields.io/badge/%E2%9C%A8-Questions-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
</a>
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
@@ -24,7 +24,7 @@
Roadmaps are now interactive, you can click the nodes to read more about the topics.
### [View all Roadmaps](https://roadmap.sh)
### [View all Roadmaps](https://roadmap.sh) &nbsp;&middot;&nbsp; [Best Practices](https://roadmap.sh/best-practices) &nbsp;&middot;&nbsp; [Questions](https://roadmap.sh/questions)
![](https://i.imgur.com/waxVImv.png)
@@ -39,6 +39,7 @@ Here is the list of available roadmaps with more being actively worked upon.
- [QA Roadmap](https://roadmap.sh/qa)
- [Python Roadmap](https://roadmap.sh/python)
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
- [Game Developer Roadmap](https://roadmap.sh/game-developer)
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
- [JavaScript Roadmap](https://roadmap.sh/javascript)
- [TypeScript Roadmap](https://roadmap.sh/typescript)
@@ -67,13 +68,18 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Docker Roadmap](https://roadmap.sh/docker)
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
We have also added a new form of visual content covering best practices:
There are also interactive best practices:
- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review)
- [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance)
- [API Security Best Practices](https://roadmap.sh/best-practices/api-security)
- [AWS Best Practices](https://roadmap.sh/best-practices/aws)
..and questions to help you test, rate and improve your knowledge
- [JavaScript Questions](https://roadmap.sh/questions/javascript)
- [React Questions](https://roadmap.sh/questions/react)
![](https://i.imgur.com/waxVImv.png)
## Share with the community

View File

@@ -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

View File

@@ -19,13 +19,12 @@ if (!allowedRoadmapIds.includes(roadmapId)) {
}
const ROADMAP_CONTENT_DIR = path.join(ALL_ROADMAPS_DIR, roadmapId, 'content');
const { Configuration, OpenAIApi } = require('openai');
const configuration = new Configuration({
const OpenAI = require('openai');
const openai = new OpenAI({
apiKey: OPEN_AI_API_KEY,
});
const openai = new OpenAIApi(configuration);
function getFilesInFolder(folderPath, fileList = {}) {
const files = fs.readdirSync(folderPath);
@@ -60,16 +59,16 @@ function writeTopicContent(currTopicUrl) {
const roadmapTitle = roadmapId.replace(/-/g, ' ');
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output.`;
if (!childTopic) {
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output.`;
}
console.log(`Generating '${childTopic || parentTopic}'...`);
return new Promise((resolve, reject) => {
openai
.createChatCompletion({
openai.chat.completions
.create({
model: 'gpt-4',
messages: [
{
@@ -79,7 +78,7 @@ function writeTopicContent(currTopicUrl) {
],
})
.then((response) => {
const article = response.data.choices[0].message.content;
const article = response.choices[0].message.content;
resolve(article);
})
@@ -92,7 +91,7 @@ function writeTopicContent(currTopicUrl) {
async function writeFileForGroup(group, topicUrlToPathMapping) {
const topicId = group?.properties?.controlName;
const topicTitle = group?.children?.controls?.control?.find(
(control) => control?.typeID === 'Label'
(control) => control?.typeID === 'Label',
)?.properties?.text;
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
if (!currTopicUrl) {
@@ -138,15 +137,14 @@ async function writeFileForGroup(group, topicUrlToPathMapping) {
async function run() {
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
const roadmapJson = require(path.join(
ALL_ROADMAPS_DIR,
`${roadmapId}/${roadmapId}`
));
const roadmapJson = require(
path.join(ALL_ROADMAPS_DIR, `${roadmapId}/${roadmapId}`),
);
const groups = roadmapJson?.mockup?.controls?.control?.filter(
(control) =>
control.typeID === '__group__' &&
!control.properties?.controlName?.startsWith('ext_link')
!control.properties?.controlName?.startsWith('ext_link'),
);
if (!OPEN_AI_API_KEY) {

View File

@@ -97,7 +97,7 @@ const sidebarLinks = [
}`}
>
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
Teams
Teams
</a>
</li>
{
@@ -167,13 +167,12 @@ const sidebarLinks = [
{sidebarLink.title}
</span>
{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' />
</span>
)}
{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' />
</span>
)}
{sidebarLink.id === 'friends' && (
<SidebarFriendsCounter client:load />

View File

@@ -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;
}

View File

@@ -56,6 +56,12 @@ export function GitHubButton(props: GitHubButtonProps) {
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
}
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {

View File

@@ -55,6 +55,12 @@ export function GoogleButton(props: GoogleButtonProps) {
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
}
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
@@ -86,10 +92,11 @@ export function GoogleButton(props: GoogleButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
['/respond-invite', '/befriend'].includes(window.location.pathname)
? window.location.pathname + window.location.search
: window.location.pathname;
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);

View File

@@ -55,6 +55,12 @@ export function LinkedInButton(props: LinkedInButtonProps) {
}
}
const authRedirectUrl = localStorage.getItem('authRedirect');
if (authRedirectUrl) {
localStorage.removeItem('authRedirect');
redirectUrl = authRedirectUrl;
}
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {

View File

@@ -73,7 +73,10 @@ function handleAuthenticated() {
// If the user is on a guest route, redirect them to the home page
if (guestRoutes.includes(window.location.pathname)) {
window.location.href = '/';
const authRedirect = window.localStorage.getItem('authRedirect') || '/';
window.localStorage.removeItem('authRedirect');
window.location.href = authRedirect;
}
}

View File

@@ -40,6 +40,14 @@ const defaultPages: PageType[] = [
icon: GroupIcon.src,
isProtected: true,
},
{
id: 'friends',
url: '/account/friends',
title: 'Friends',
group: 'Pages',
icon: GroupIcon.src,
isProtected: true,
},
{
id: 'roadmaps',
url: '/roadmaps',
@@ -47,6 +55,14 @@ const defaultPages: PageType[] = [
group: 'Pages',
icon: RoadmapIcon.src,
},
{
id: 'account-roadmaps',
url: '/account/roadmaps',
title: 'Custom Roadmaps',
group: 'Pages',
icon: RoadmapIcon.src,
isProtected: true,
},
{
id: 'best-practices',
url: '/best-practices',

View File

@@ -15,6 +15,7 @@ import { useToast } from '../../hooks/use-toast';
export type TeamResourceConfig = {
isCustomResource: boolean;
title: string;
description?: string;
visibility?: AllowedRoadmapVisibility;
resourceId: string;
resourceType: string;

View File

@@ -8,6 +8,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamResourceConfig } from './RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import {replaceChildren} from "../../lib/dom.ts";
export type ProgressMapProps = {
teamId: string;
@@ -81,7 +82,8 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
fontURL: '/fonts/balsamiq.woff2',
});
containerEl.current?.replaceChildren(svg);
replaceChildren(containerEl.current!, svg);
// containerEl.current?.replaceChildren(svg);
// Render team configuration
removedItems.forEach((topicId: string) => {

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { httpGet, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import { GitFork, Loader2, Map } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup';
import type { RoadmapDocument } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
type CreateVersionProps = {
roadmapId: string;
};
export function CreateVersion(props: CreateVersionProps) {
const { roadmapId } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const [userVersion, setUserVersion] = useState<RoadmapDocument>();
async function loadMyVersion() {
if (!isLoggedIn()) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<RoadmapDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-my-version/${roadmapId}`,
{},
);
if (error || !response) {
setIsLoading(false);
return;
}
setIsLoading(false);
setUserVersion(response);
}
useEffect(() => {
loadMyVersion().finally(() => {
setIsLoading(false);
});
}, []);
async function createVersion() {
if (isCreating || !roadmapId) {
return;
}
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsCreating(true);
const { response, error } = await httpPost<{ roadmapId: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-create-version/${roadmapId}`,
{},
);
if (error || !response) {
setIsCreating(false);
toast.error(error?.message || 'Failed to create version');
return;
}
window.location.href = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${response?.roadmapId}`;
}
if (isLoading) {
return (
<div className="h-[30px] w-[312px] animate-pulse rounded-md bg-gray-300"></div>
);
}
if (!isLoading && userVersion?._id) {
return (
<div className={'flex items-center'}>
<a
href={`/r?id=${userVersion._id}`}
className="flex items-center rounded-md border border-blue-400 bg-gray-50 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:hover:bg-gray-100 max-sm:hidden sm:text-sm"
>
<Map size="15px" className="mr-1.5" />
Visit your own version of this Roadmap
</a>
</div>
);
}
if (isConfirming) {
return (
<p className="flex h-[30px] items-center text-sm text-red-500">
Create and edit a custom roadmap from this roadmap?
<button
onClick={() => {
setIsConfirming(false);
createVersion().finally(() => null);
}}
className="ml-2 font-semibold underline underline-offset-2"
>
Yes
</button>
<span className="text-xs">&nbsp;/&nbsp;</span>
<button
className="font-semibold underline underline-offset-2"
onClick={() => setIsConfirming(false)}
>
No
</button>
</p>
);
}
return (
<button
disabled={isCreating}
className="flex items-center justify-center rounded-md border border-gray-300 bg-gray-50 px-2.5 py-1 text-xs font-medium text-black hover:bg-gray-200 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:hover:bg-gray-100 max-sm:hidden sm:text-sm"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsConfirming(true);
}}
>
{isCreating ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin stroke-[2.5]" />
Please wait ..
</>
) : (
<>
<GitFork className="mr-1.5" size="16px" />
Create your own version of this roadmap
</>
)}
</button>
);
}

View File

@@ -2,20 +2,17 @@ import { Plus } from 'lucide-react';
import { isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup';
import { cn } from '../../../lib/classname';
import {
type AllowedCustomRoadmapType,
type AllowedRoadmapVisibility,
CreateRoadmapModal,
} from './CreateRoadmapModal';
import { CreateRoadmapModal } from './CreateRoadmapModal';
import { useState } from 'react';
type CreateRoadmapButtonProps = {
className?: string;
type?: AllowedCustomRoadmapType;
text?: string;
teamId?: string;
};
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
const { className, type } = props;
const { teamId, className, text = 'Create your own Roadmap' } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
@@ -31,7 +28,7 @@ export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
type={type}
teamId={teamId}
onClose={() => {
setIsCreatingRoadmap(false);
}}
@@ -41,12 +38,12 @@ 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}
>
<Plus size={16} />
Create a new roadmap
{text}
</button>
</>
);

View File

@@ -10,7 +10,6 @@ import { Modal } from '../../Modal';
import { useToast } from '../../../hooks/use-toast';
import { httpPost } from '../../../lib/http';
import { cn } from '../../../lib/classname';
import { allowedVisibilityLabels } from '../ShareRoadmapModal';
export const allowedRoadmapVisibility = [
'me',
@@ -30,6 +29,7 @@ export interface RoadmapDocument {
description?: string;
creatorId: string;
teamId?: string;
isDiscoverable: boolean;
type: AllowedCustomRoadmapType;
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
@@ -46,12 +46,11 @@ interface CreateRoadmapModalProps {
onClose: () => void;
onCreated?: (roadmap: RoadmapDocument) => void;
teamId?: string;
type?: AllowedCustomRoadmapType;
visibility?: AllowedRoadmapVisibility;
}
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
const { onClose, onCreated, teamId, type: defaultType = 'role' } = props;
const { onClose, onCreated, teamId } = props;
const titleRef = useRef<HTMLInputElement>(null);
const toast = useToast();
@@ -59,19 +58,18 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<AllowedCustomRoadmapType>(defaultType);
const isInvalidDescription = description?.trim().length > 80;
async function handleSubmit(
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
redirect: boolean = true
redirect: boolean = true,
) {
e.preventDefault();
if (isLoading) {
return;
}
if (title.trim() === '' || isInvalidDescription || !type) {
if (title.trim() === '' || isInvalidDescription) {
toast.error('Please fill all the fields');
return;
}
@@ -82,13 +80,12 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
{
title,
description,
type,
...(teamId && {
teamId,
}),
nodes: [],
edges: [],
}
},
);
if (error) {
@@ -99,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;
}
@@ -114,7 +111,6 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
setTitle('');
setDescription('');
setType('role');
setIsLoading(false);
}
@@ -149,7 +145,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
name="title"
id="title"
required
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
className="block text-black w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
placeholder="Enter Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -169,7 +165,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
name="description"
required
className={cn(
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
'block text-black h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
isInvalidDescription && 'border-red-300 bg-red-100'
)}
placeholder="Enter Description"
@@ -182,33 +178,6 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
</div>
</div>
<div className="mt-4">
<label
htmlFor="type"
className="block text-xs uppercase text-gray-400"
>
Type
</label>
<div className="mt-1">
<select
id="type"
name="type"
required
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
value={type}
onChange={(e) =>
setType(e.target.value as AllowedCustomRoadmapType)
}
>
{allowedCustomRoadmapType.map((type) => (
<option key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1)} Based Roadmap
</option>
))}
</select>
</div>
</div>
<div
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')}
>
@@ -217,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
@@ -244,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 ? (

View File

@@ -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',
@@ -40,6 +39,18 @@ export interface RoadmapContentDocument {
}[];
}
export type CreatorType = {
id: string;
name: string;
avatar: string;
};
export type GetRoadmapResponse = RoadmapDocument & {
canManage: boolean;
creator?: CreatorType;
team?: CreatorType;
};
export function hideRoadmapLoader() {
const loaderEl = document.querySelector(
'[data-roadmap-loader]'
@@ -53,7 +64,7 @@ export function CustomRoadmap() {
const { id, secret } = getUrlParams() as { id: string; secret: string };
const [isLoading, setIsLoading] = useState(true);
const [roadmap, setRoadmap] = useState<RoadmapDocument | null>(null);
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
const [error, setError] = useState<AppError | FetchError | undefined>();
async function getRoadmap() {
@@ -66,7 +77,7 @@ export function CustomRoadmap() {
roadmapUrl.searchParams.set('secret', secret);
}
const { response, error } = await httpGet<RoadmapDocument>(
const { response, error } = await httpGet<GetRoadmapResponse>(
roadmapUrl.toString()
);
@@ -109,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}
/>
</>
);
}

View File

@@ -1,11 +1,31 @@
import { CircleSlash } from 'lucide-react';
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, className } = props;
const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`;
export function EmptyRoadmap() {
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-4">This roadmap is currently empty.</h3>
<h3 className="mt-2">This roadmap is currently empty.</h3>
{canManage && (
<a
href={editUrl}
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="mr-2 inline-block h-4 w-4" />
Edit Roadmap
</a>
)}
</div>
</div>
);

View 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"
/>
</>
);
}

View File

@@ -7,6 +7,7 @@ import {
Globe,
LockIcon,
Users,
PenSquare,
} from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import {
@@ -60,6 +61,8 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
const shareSettingsModal = selectedRoadmap && (
<ShareOptionsModal
isDiscoverable={selectedRoadmap.isDiscoverable}
description={selectedRoadmap.description}
visibility={selectedRoadmap.visibility}
sharedFriendIds={selectedRoadmap.sharedFriendIds}
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds}
@@ -140,7 +143,7 @@ function CustomRoadmapItem(props: CustomRoadmapItemProps) {
return (
<li
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_172px]"
key={roadmap._id!}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
@@ -172,10 +175,20 @@ function CustomRoadmapItem(props: CustomRoadmapItemProps) {
}}
/>
<a
href={editorLink}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-xs text-black hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<PenSquare className="inline-block h-4 w-4" />
Edit
</a>
<a
href={`/r?id=${roadmap._id}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
'ml-2 flex items-center gap-2 rounded-md border border-blue-400 bg-white px-2 py-1.5 text-xs hover:bg-blue-50 focus:outline-none text-blue-600'
}
target={'_blank'}
>

View File

@@ -24,6 +24,8 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
<>
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
<ShareOptionsModal
isDiscoverable={$currentRoadmap.isDiscoverable}
description={$currentRoadmap?.description}
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
@@ -41,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,

View File

@@ -23,7 +23,7 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center rounded-md bg-gray-500 py-1.5 pl-2 pr-2 text-xs font-medium text-white hover:bg-gray-600 sm:pl-1.5 sm:pr-3 sm:text-sm"
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:pl-1.5 sm:pr-3 sm:text-sm"
>
<MoreVertical className="mr-0 h-4 w-4 stroke-[2.5] sm:mr-1.5" />
<span className="hidden sm:inline">Actions</span>
@@ -32,7 +32,7 @@ export function RoadmapActionButton(props: RoadmapActionButtonProps) {
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-0 top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
className="align-right absolute right-0 top-full mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md z-[9999]"
>
<ul>
{onUpdateSharing && (

View File

@@ -8,6 +8,9 @@ import { httpDelete, httpPut } from '../../lib/http';
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { RoadmapActionButton } from './RoadmapActionButton';
import { Lock, Shapes } from 'lucide-react';
import { Modal } from '../Modal';
import { ShareSuccess } from '../ShareOptions/ShareSuccess';
type RoadmapHeaderProps = {};
@@ -15,9 +18,17 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
const $currentRoadmap = useStore(currentRoadmap);
const { title, description, _id: roadmapId } = useStore(currentRoadmap) || {};
const {
title,
description,
_id: roadmapId,
creator,
team,
visibility,
} = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false);
const [isSharingWithOthers, setIsSharingWithOthers] = useState(false);
const toast = useToast();
async function deleteResource() {
@@ -54,10 +65,53 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
}
}
const avatarUrl = creator?.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
: '/images/default-avatar.png';
const sharingWithOthersModal = isSharingWithOthers && (
<Modal
onClose={() => setIsSharingWithOthers(false)}
wrapperClassName="max-w-lg"
bodyClassName="p-4 flex flex-col"
>
<ShareSuccess
visibility="public"
roadmapId={roadmapId!}
description={description}
onClose={() => setIsSharingWithOthers(false)}
isSharingWithOthers={true}
/>
</Modal>
);
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="mb-3 mt-0 sm:mb-4">
{creator?.name && (
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
<img
alt={creator.name}
src={avatarUrl}
className="h-5 w-5 rounded-full"
/>
<span>
Created by&nbsp;
<span className="font-semibold text-gray-900">
{creator?.name}
</span>
{team && (
<>
&nbsp;from&nbsp;
<span className="font-semibold text-gray-900">
{team?.name}
</span>
</>
)}
</span>
</div>
)}
<div className="mb-3 mt-4 sm:mb-4">
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
{description}
@@ -83,52 +137,78 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
<span className="ml-2">Subscribe</span>
</button>
</div>
{$canManageCurrentRoadmap && (
<div className="flex items-center gap-2">
{isSharing && $currentRoadmap && (
<ShareOptionsModal
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={
$currentRoadmap?.sharedTeamMemberIds || []
}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
<div className="flex items-center gap-2">
{$canManageCurrentRoadmap && (
<>
{isSharing && $currentRoadmap && (
<ShareOptionsModal
isDiscoverable={$currentRoadmap.isDiscoverable}
description={$currentRoadmap?.description}
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={
$currentRoadmap?.sharedTeamMemberIds || []
}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<a
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
$currentRoadmap?._id
}`}
target="_blank"
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
<span className="hidden sm:inline-block">Edit Roadmap</span>
<span className="sm:hidden">Edit</span>
</a>
<button
onClick={() => setIsSharing(true)}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Sharing
</button>
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {
return;
}
deleteResource().finally(() => null);
}}
/>
)}
</>
)}
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {
return;
}
deleteResource().finally(() => null);
}}
onCustomize={() => {
const editorLink = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`;
window.open(editorLink, '_blank');
}}
onUpdateSharing={() => {
setIsSharing(true);
}}
/>
</div>
)}
{!$canManageCurrentRoadmap && visibility === 'public' && (
<>
{sharingWithOthersModal}
<button
onClick={() => setIsSharingWithOthers(true)}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
>
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
Share with Others
</button>
</>
)}
</div>
</div>
<RoadmapHint

View File

@@ -86,13 +86,13 @@ export function RoadmapListPage() {
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="mb-6 flex flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div className="flex grow items-center gap-2">
{tabTypes.map((tab) => {
return (
<button
key={tab.value}
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
className={`relative flex w-full items-center justify-center whitespace-nowrap rounded-md border p-1 px-3 text-sm sm:w-auto ${
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
} w-full sm:w-auto`}
onClick={() => setActiveTab(tab.value)}

View File

@@ -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;
}

View File

@@ -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';
import { isLoggedIn } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
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 />}
</div>
</div>
);
}

View File

@@ -2,14 +2,22 @@ export function SkeletonRoadmapHeader() {
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="mb-3 mt-0 sm:mb-4">
<div className="flex items-center gap-1.5">
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
<div className="h-5 w-5/12 animate-pulse rounded-md bg-gray-200" />
</div>
<div className="mb-3 mt-4 sm:mb-4">
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" />
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" />
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className="h-7 w-[35.04px] sm:w-32 animate-pulse rounded-md bg-gray-300 sm:h-8" />
<div className="h-7 w-[32px] sm:w-[89.73px] animate-pulse rounded-md bg-gray-300 sm:h-8" />
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
<div className="flex items-center gap-2">
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
</div>
</div>
<div className="mb-0 mt-4 rounded-md border-0 sm:-mb-[65px] sm:mt-7 sm:border">

View File

@@ -42,13 +42,7 @@ const {
{
showCreateRoadmap && (
<li>
<CreateRoadmapButton
client:load
className='min-h-[54px]'
type={
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
}
/>
<CreateRoadmapButton client:load className='min-h-[54px]' />
</li>
)
}

View File

@@ -31,7 +31,8 @@ import Icon from './AstroIcon.astro';
<a
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'>YouTube</a>
target='_blank'>YouTube</a
>
</p>
<div class='flex flex-col justify-between gap-12 sm:flex-row'>
@@ -47,7 +48,7 @@ import Icon from './AstroIcon.astro';
<span class='mx-2 text-gray-400'>by</span>
<a
class='font-regular rounded-md bg-blue-600 px-1.5 py-1 text-sm hover:bg-blue-700'
href='https://twitter.com/intent/user?screen_name=kamrify'
href='https://twitter.com/kamrify'
target='_blank'
>
<span class='hidden sm:inline'>@kamrify</span>
@@ -67,20 +68,30 @@ import Icon from './AstroIcon.astro';
<a href='/privacy' class='hover:text-white'>Privacy</a>
<span class='mx-1.5'>&middot;</span>
<a
aria-label="Subscribe to YouTube channel"
aria-label='Write us an email'
href='mailto:info@roadmap.sh'
class='hover:text-white'
>
<AstroIcon icon='letter' class='inline-block h-5 w-5' />
</a>
<a
aria-label='Subscribe to YouTube channel'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'
class='hover:text-white'
class='ml-2 hover:text-white'
>
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
aria-label="Follow on Twitter"
aria-label='Follow on Twitter'
href='https://twitter.com/roadmapsh'
target='_blank'
class='ml-2 hover:text-white'
>
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
<AstroIcon
icon='twitter-fill'
class='inline-block h-5 w-5 fill-current'
/>
</a>
</p>
</div>

View File

@@ -10,6 +10,7 @@ import {
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { showLoginPopup } from '../../lib/popup';
import {replaceChildren} from "../../lib/dom.ts";
export class Renderer {
resourceId: string;
@@ -88,7 +89,8 @@ export class Renderer {
});
})
.then((svg) => {
this.containerEl?.replaceChildren(svg);
replaceChildren(this.containerEl!, svg);
// this.containerEl?.replaceChildren(svg);
})
.then(() => {
return renderResourceProgress(

View File

@@ -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">

View File

@@ -1,23 +1,30 @@
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { TeamAnnouncement } from '../TeamAnnouncement';
type EmptyProgressProps = {
title?: string;
message?: string;
title?: string;
message?: string;
};
export function EmptyProgress(props: EmptyProgressProps) {
const {
title = 'Start learning ..',
message = 'Your progress and favorite roadmaps will show up here.',
} = props;
const {
title = 'Start learning ..',
message = 'Your progress and favorite roadmaps will show up here.',
} = props;
return (
<div className="relative flex min-h-full flex-col items-start sm:items-center justify-center py-6">
<h2 className={'mb-1 flex items-center text-lg sm:text-2xl text-gray-200'}>
<CheckIcon additionalClasses='mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]' />
Start learning ..
</h2>
<p className={'text-gray-400 text-sm sm:text-base'}>{message}</p>
</div>
);
return (
<div className="relative flex min-h-full flex-col items-start justify-center py-6 sm:items-center">
<h2
className={'mb-1.5 flex items-center text-lg text-gray-200 sm:text-2xl'}
>
<CheckIcon additionalClasses="mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]" />
{title}
</h2>
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
<p className="mt-5">
<TeamAnnouncement />
</p>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { HeroRoadmaps } from './HeroRoadmaps';
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
import { isLoggedIn } from '../../lib/jwt';
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
export type UserProgressResponse = {
resourceId: string;
@@ -15,6 +16,11 @@ export type UserProgressResponse = {
total: number;
updatedAt: Date;
isCustomResource: boolean;
team?: {
name: string;
id: string;
role: AllowedMemberRoles;
};
}[];
function renderProgress(progressList: UserProgressResponse) {
@@ -114,25 +120,43 @@ export function FavoriteRoadmaps() {
}
const hasProgress = progress?.length > 0;
const customRoadmaps = progress?.filter((p) => p.isCustomResource);
const customRoadmaps = progress?.filter(
(p) => p.isCustomResource && !p.team?.name
);
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
const teamRoadmaps: HeroTeamRoadmaps = progress
?.filter((p) => p.isCustomResource && p.team?.name)
.reduce((acc: HeroTeamRoadmaps, curr) => {
const currTeam = curr.team!;
if (!acc[currTeam.name]) {
acc[currTeam.name] = [];
}
acc[currTeam.name].push(curr);
return acc;
}, {});
return (
<div
className={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
>
<div className="container min-h-full">
{!isLoading && progress?.length == 0 && <EmptyProgress />}
{hasProgress && (
<HeroRoadmaps
showCustomRoadmaps={true}
customRoadmaps={customRoadmaps}
progress={defaultRoadmaps}
isLoading={isLoading}
/>
)}
<div
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
>
<div className="container min-h-full">
{!isLoading && progress?.length == 0 && <EmptyProgress />}
{hasProgress && (
<HeroRoadmaps
teamRoadmaps={teamRoadmaps}
customRoadmaps={customRoadmaps}
progress={defaultRoadmaps}
isLoading={isLoading}
/>
)}
</div>
</div>
</div>
);

View File

@@ -3,10 +3,11 @@ import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { MapIcon } from 'lucide-react';
import { MapIcon, Users2 } from 'lucide-react';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { useState } from 'react';
import { type ReactNode, useState } from 'react';
import { TeamAnnouncement } from '../TeamAnnouncement';
type ProgressRoadmapProps = {
url: string;
@@ -55,7 +56,7 @@ function HeroRoadmap(props: ProgressRoadmapProps) {
type ProgressTitleProps = {
icon: any;
isLoading?: boolean;
title: string;
title: string | ReactNode;
};
export function HeroTitle(props: ProgressTitleProps) {
@@ -73,22 +74,39 @@ export function HeroTitle(props: ProgressTitleProps) {
</p>
);
}
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>;
type ProgressListProps = {
progress: UserProgressResponse;
customRoadmaps: UserProgressResponse;
teamRoadmaps?: HeroTeamRoadmaps;
isLoading?: boolean;
};
export function HeroRoadmaps(props: ProgressListProps) {
const { progress, isLoading = false, customRoadmaps } = props;
const {
teamRoadmaps = {},
progress,
isLoading = false,
customRoadmaps,
} = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [creatingRoadmapTeamId, setCreatingRoadmapTeamId] = useState<string>();
return (
<div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-7 mt-2 text-sm">
<TeamAnnouncement />
</p>
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
<CreateRoadmapModal
teamId={creatingRoadmapTeamId}
onClose={() => {
setIsCreatingRoadmap(false);
setCreatingRoadmapTeamId(undefined);
}}
/>
)}
{
<HeroTitle
@@ -164,6 +182,83 @@ export function HeroRoadmaps(props: ProgressListProps) {
</div>
)}
</div>
{Object.keys(teamRoadmaps).map((teamName) => {
const currentTeam: UserProgressResponse[0]['team'] =
teamRoadmaps?.[teamName]?.[0]?.team;
const roadmapsList = teamRoadmaps[teamName].filter(
(roadmap) => !!roadmap.resourceTitle
);
const canManageTeam = ['admin', 'manager'].includes(currentTeam?.role!);
return (
<div className="mt-5" key={teamName}>
{
<HeroTitle
icon={<Users2 className="mr-1.5 h-[14px] w-[14px]" />}
title={
<>
Team{' '}
<a
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
href={`/team/progress?t=${currentTeam?.id}`}
>
{teamName}
</a>
Roadmaps
</>
}
/>
}
{roadmapsList.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
Team does not have any roadmaps yet.{' '}
{canManageTeam && (
<button
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => {
setCreatingRoadmapTeamId(currentTeam?.id);
setIsCreatingRoadmap(true);
}}
>
Create one!
</button>
)}
</p>
)}
{roadmapsList.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{roadmapsList.map((customRoadmap) => {
return (
<HeroRoadmap
key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'}
resourceTitle={customRoadmap.resourceTitle}
percentageDone={
((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r?id=${customRoadmap.resourceId}`}
allowFavorite={false}
/>
);
})}
{canManageTeam && (
<CreateRoadmapButton
teamId={currentTeam?.id}
text="Create Team Roadmap"
/>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,12 +1,19 @@
---
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
import {TeamAnnouncement} from "../TeamAnnouncement";
---
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
<div
class='min-h-auto relative min-h-[192px] border-b border-b-[#1e293c] sm:min-h-[281px] transition-all'
>
<div
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
id='hero-text'
>
<p class='-mt-4 sm:-mt-10 mb-7'>
<TeamAnnouncement />
</p>
<h1
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
>
@@ -24,5 +31,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
their career.
</p>
</div>
<FavoriteRoadmaps client:only="react" />
<FavoriteRoadmaps client:only='react' />
</div>

View File

@@ -4,12 +4,14 @@ import { isLoggedIn } from '../../lib/jwt';
import { AccountDropdownList } from './AccountDropdownList';
import { DropdownTeamList } from './DropdownTeamList';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
export function AccountDropdown() {
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
@@ -22,6 +24,14 @@ export function AccountDropdown() {
return (
<div className="relative z-50 animate-fade-in">
{isCreatingRoadmap && (
<CreateRoadmapModal
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<button
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
onClick={() => {
@@ -43,7 +53,13 @@ export function AccountDropdown() {
{isTeamsOpen ? (
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
) : (
<AccountDropdownList setIsTeamsOpen={setIsTeamsOpen} />
<AccountDropdownList
onCreateRoadmap={() => {
setIsCreatingRoadmap(true);
setShowDropdown(false);
}}
setIsTeamsOpen={setIsTeamsOpen}
/>
)}
</div>
)}

View File

@@ -1,46 +1,76 @@
import { ChevronRight } from 'lucide-react';
import { ChevronRight, LogOut, Map, Plus, User2, Users2 } from 'lucide-react';
import { logout } from './navigation';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal.tsx';
import { useState } from 'react';
type AccountDropdownListProps = {
onCreateRoadmap: () => void;
setIsTeamsOpen: (isOpen: boolean) => void;
};
export function AccountDropdownList(props: AccountDropdownListProps) {
const { setIsTeamsOpen } = props;
const { setIsTeamsOpen, onCreateRoadmap } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
return (
<ul>
<li className="px-1">
<a
href="/account"
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<User2 className="h-4 w-4 stroke-[2.5px] text-slate-400 group-hover:text-white" />
Profile
</a>
</li>
<li className="px-1">
<a
href="/account/friends"
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Users2 className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Friends
</a>
</li>
<li className="px-1">
<li className="mt-1 border-t border-t-gray-700/60 px-1 pt-1">
<button
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
onClick={() => {
onCreateRoadmap();
}}
className="group flex w-full items-center gap-2 rounded py-2 pl-3 pr-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Plus className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
New Roadmap
</button>
</li>
<li className="border-b border-b-gray-700/60 px-1 pb-1">
<a
href="/account/roadmaps"
className="group flex items-center gap-2 rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Map className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Roadmaps
</a>
</li>
<li className="px-1 pt-1">
<button
className="group flex w-full items-center justify-between rounded py-2 pl-3 pr-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
onClick={() => setIsTeamsOpen(true)}
>
Teams
<span className="flex items-center gap-2.5">
<Users2 className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Teams
</span>
<ChevronRight className="h-4 w-4 shrink-0 stroke-[2.5px] text-slate-400 group-hover:text-white" />
</button>
</li>
<li className="px-1">
<button
className="block w-full rounded pl-4 pr-2 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
className="group flex gap-2 items-center w-full rounded py-2 pl-3 pr-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
type="button"
onClick={logout}
>
<LogOut className="h-4 w-4 stroke-[2px] text-slate-400 group-hover:text-white" />
Logout
</button>
</li>

View File

@@ -2,7 +2,6 @@
import Icon from '../AstroIcon.astro';
import { AccountDropdown } from './AccountDropdown';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
<nav class='container flex items-center justify-between'>
<a
@@ -27,8 +26,25 @@ import { AccountDropdown } from './AccountDropdown';
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
>
</li>
<li class='hidden lg:inline'>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
<li>
<a href='/teams' class='group relative text-blue-300 hover:text-white'>
Teams
<span
class='ml-0.5 hidden rounded-sm border-black bg-blue-300 px-1 py-0.5 text-xs font-semibold uppercase text-black group-hover:bg-white md:inline'
>
New
</span>
<span class='inline md:hidden absolute -right-[11px] top-0'>
<span class='relative flex h-2 w-2'>
<span
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
></span>
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
></span>
</span>
</span>
</a>
</li>
<li>
<kbd
@@ -45,7 +61,7 @@ import { AccountDropdown } from './AccountDropdown';
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
</li>
<li>
<AccountDropdown client:only="react" />
<AccountDropdown client:only='react' />
<a
data-guest-required

View File

@@ -105,7 +105,7 @@ export function PageSponsor(props: PageSponsorProps) {
</span>
<img
src={imageUrl}
className="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
className="block h-[150px] object-fill lg:h-[169px] lg:w-[118.18px]"
alt="Sponsor Banner"
/>
<span className="flex flex-1 flex-col justify-between text-sm">

View File

@@ -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

View File

@@ -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

View File

@@ -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={() => {

View File

@@ -1,5 +1,7 @@
---
import { getQuestionGroupsByIds } from '../lib/question-group';
import { getRoadmapsByIds, RoadmapFrontmatter } from '../lib/roadmap';
import { Map, Clipboard } from 'lucide-react';
export interface Props {
roadmap: RoadmapFrontmatter;
@@ -8,35 +10,89 @@ export interface Props {
const { roadmap } = Astro.props;
const relatedRoadmaps = roadmap.relatedRoadmaps || [];
if (!relatedRoadmaps.length) {
return null;
}
const relatedRoadmapDetails = await getRoadmapsByIds(relatedRoadmaps);
const relatedQuestions = roadmap.relatedQuestions || [];
const relatedQuestionDetails = await getQuestionGroupsByIds(relatedQuestions);
---
<div class='border-t bg-gray-100'>
<div class='container'>
<div class='flex justify-between relative -top-5'>
<span class='text-md font-medium py-1 px-3 border bg-white rounded-md'>Related Roadmaps</span>
<a href='/roadmaps' class='text-md font-medium py-1 px-3 border bg-white rounded-md hover:bg-gray-50'>
<span class='hidden sm:inline'>All Roadmaps &rarr;</span>
<span class='inline sm:hidden'>More &rarr;</span>
</a>
</div>
<div class='flex flex-col gap-1 pb-8'>
{
relatedRoadmapDetails.map((relatedRoadmap) => (
{
relatedQuestionDetails.length > 0 && (
<div class='border-t bg-gray-100 pb-3'>
<div class='container'>
<div class='relative -top-5 flex justify-between'>
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
<Clipboard className='mr-1.5 text-black' size='17px' />
Test your Knowledge
<span class='ml-2 rounded-md border border-yellow-300 bg-yellow-100 px-1 py-0.5 text-xs uppercase'>
New
</span>
</span>
<a
href={`/${relatedRoadmap.id}`}
class='py-2 px-3.5 bg-white border rounded-md hover:bg-gray-50 flex flex-col sm:flex-row gap-0.5 sm:gap-0'
href='/roadmaps'
class='text-md rounded-md border bg-white px-3 py-1 font-medium hover:bg-gray-50'
>
<span class='font-medium inline-block min-w-[150px]'>{relatedRoadmap.frontmatter.briefTitle}</span>
<span class='text-gray-500'>{relatedRoadmap.frontmatter.briefDescription}</span>
<span class='hidden sm:inline'>All Quizzes &rarr;</span>
<span class='inline sm:hidden'>More &rarr;</span>
</a>
))
}
</div>
<div class='flex flex-col gap-1 pb-8'>
{relatedQuestionDetails.map((relatedQuestionGroup) => (
<a
href={`/questions/${relatedQuestionGroup.id}`}
class='flex flex-col gap-0.5 rounded-md border bg-white px-3.5 py-2 hover:bg-gray-50 sm:flex-row sm:gap-0'
>
<span class='inline-block min-w-[150px] font-medium'>
{relatedQuestionGroup.title}
</span>
<span class='text-gray-500'>
{relatedQuestionGroup.description}
</span>
</a>
))}
</div>
</div>
</div>
</div>
</div>
)
}
{
relatedRoadmaps.length && (
<div class:list={['border-t bg-gray-100', {
'mt-8': !relatedQuestionDetails.length
}]}>
<div class='container'>
<div class='relative -top-5 flex justify-between'>
<span class='text-md flex items-center rounded-md border bg-white px-3 py-1 font-medium'>
<Map className='text-black mr-1.5' size='17px' />
Related Roadmaps
</span>
<a
href='/roadmaps'
class='text-md rounded-md border bg-white px-3 py-1 font-medium hover:bg-gray-50'
>
<span class='hidden sm:inline'>All Roadmaps &rarr;</span>
<span class='inline sm:hidden'>More &rarr;</span>
</a>
</div>
<div class='flex flex-col gap-1 pb-8'>
{relatedRoadmapDetails.map((relatedRoadmap) => (
<a
href={`/${relatedRoadmap.id}`}
class='flex flex-col gap-0.5 rounded-md border bg-white px-3.5 py-2 hover:bg-gray-50 sm:flex-row sm:gap-0'
>
<span class='inline-block min-w-[150px] font-medium'>
{relatedRoadmap.frontmatter.briefTitle}
</span>
<span class='text-gray-500'>
{relatedRoadmap.frontmatter.briefDescription}
</span>
</a>
))}
</div>
</div>
</div>
)
}

View File

@@ -8,7 +8,8 @@ import YouTubeAlert from './YouTubeAlert.astro';
import ProgressHelpPopup from './ProgressHelpPopup.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { TeamVersions } from './TeamVersions/TeamVersions';
import { RoadmapFrontmatter } from '../lib/roadmap';
import { CreateVersion } from './CreateVersion/CreateVersion';
import { type RoadmapFrontmatter } from '../lib/roadmap';
export interface Props {
title: string;
@@ -20,6 +21,7 @@ export interface Props {
hasSearch?: boolean;
question?: RoadmapFrontmatter['question'];
hasTopics?: boolean;
isForkable?: boolean;
}
const {
@@ -32,6 +34,7 @@ const {
note,
hasTopics = false,
question,
isForkable = false,
} = Astro.props;
const isRoadmapReady = !isUpcoming;
@@ -58,13 +61,21 @@ const hasTnsBanner = !!tnsBannerLink;
]}
>
<div class='mb-3 mt-0 sm:mb-4'>
{
isForkable && (
<div class='mb-2'>
<CreateVersion client:load roadmapId={roadmapId} />
</div>
)
}
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
<span class='relative top-0 sm:-top-1'>
<MarkFavorite
resourceId={roadmapId}
resourceType='roadmap'
className='text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-4 sm:[&>svg]:w-4 ml-1.5 relative focus:outline-0'
className='relative ml-1.5 text-gray-500 !opacity-100 hover:text-gray-600 focus:outline-0 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:stroke-gray-400 [&>svg]:stroke-[0.4] hover:[&>svg]:stroke-gray-600 sm:[&>svg]:h-4 sm:[&>svg]:w-4'
client:only='react'
/>
</span>

View File

@@ -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>

View File

@@ -1,64 +0,0 @@
import { CheckCircle, Copy } from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import { cn } from '../../lib/classname';
type CopyRoadmapLinkProps = {
roadmapId: string;
onClose: () => void;
};
export function CopyRoadmapLink(props: CopyRoadmapLinkProps) {
const { roadmapId, onClose } = props;
const shareLink = `${
import.meta.env.PUBLIC_ROADMAP_WEB_URL
}/r?id=${roadmapId}`;
const { copyText, isCopied } = useCopyText();
return (
<div className="flex grow flex-col justify-center">
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5">
<CheckCircle className="h-14 w-14 text-green-500" />
<h3 className="text-xl font-medium">Sharing Settings Updated</h3>
</div>
<input
type="text"
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none"
value={shareLink}
readOnly
onClick={(e) => {
e.currentTarget.select();
copyText(shareLink);
}}
/>
<p className="mt-1 text-sm text-gray-400">
You can share the above link with anyone who has access
</p>
<div className="mt-4 flex flex-col items-center justify-end gap-2">
<button
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
isCopied && 'bg-green-300 text-green-800'
)}
disabled={isCopied}
onClick={() => {
copyText(shareLink);
}}
>
<Copy className="h-3.5 w-3.5 stroke-[2.5]" />
{isCopied ? 'Copied' : 'Copy'}
</button>
<button
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
)}
onClick={onClose}
>
Close
</button>
</div>
</div>
);
}

View File

@@ -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,32 +122,85 @@ 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);
return (
<li key={friend.userId}>
<UserItem
user={{
name: friend.name,
avatar: friend.avatar,
email: friend.email,
}}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setSharedFriendIds(
sharedFriendIds.filter((id) => id !== friend.userId)
);
} else {
setSharedFriendIds([...sharedFriendIds, friend.userId]);
}
<>
<ul className="mt-2 grid grid-cols-3 gap-1.5">
{friends.map((friend) => {
const isSelected = sharedFriendIds?.includes(friend.userId);
return (
<li key={friend.userId}>
<UserItem
user={{
name: friend.name,
avatar: friend.avatar,
email: friend.email,
}}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setSharedFriendIds(
sharedFriendIds.filter((id) => id !== friend.userId),
);
} else {
setSharedFriendIds([...sharedFriendIds, friend.userId]);
}
}}
/>
</li>
);
})}
</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,
},
)}
/>
</li>
);
})}
</ul>
<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>

View File

@@ -1,4 +1,10 @@
import { type ReactNode, useCallback, useState } from 'react';
import {
type ReactNode,
useCallback,
useState,
useMemo,
useEffect,
} from 'react';
import { Globe2, Loader2, Lock } from 'lucide-react';
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList';
import { TransferToTeamList } from './TransferToTeamList';
@@ -7,7 +13,7 @@ import {
ShareTeamMemberList,
type TeamMemberList,
} from './ShareTeamMemberList';
import { CopyRoadmapLink } from './CopyRoadmapLink';
import { ShareSuccess } from './ShareSuccess';
import { useToast } from '../../hooks/use-toast';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { httpPatch } from '../../lib/http';
@@ -16,6 +22,7 @@ import { cn } from '../../lib/classname';
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
export type OnShareSettingsUpdate = (options: {
isDiscoverable: boolean;
visibility: AllowedRoadmapVisibility;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
@@ -24,10 +31,12 @@ export type OnShareSettingsUpdate = (options: {
type ShareOptionsModalProps = {
onClose: () => void;
visibility: AllowedRoadmapVisibility;
isDiscoverable?: boolean;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
teamId?: string;
roadmapId?: string;
description?: string;
onShareSettingsUpdate: OnShareSettingsUpdate;
};
@@ -36,11 +45,13 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
const {
roadmapId,
onClose,
isDiscoverable: defaultIsDiscoverable = false,
visibility: defaultVisibility,
sharedTeamMemberIds: defaultSharedMemberIds = [],
sharedFriendIds: defaultSharedFriendIds = [],
teamId,
onShareSettingsUpdate,
description,
} = props;
const toast = useToast();
@@ -49,9 +60,13 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
const [friends, setFriends] = useState<ListFriendsResponse>([]);
const [teams, setTeams] = useState<UserTeamItem[]>([]);
const [members, setMembers] = useState<TeamMemberList[]>([]);
// Using global team members loading state to avoid glitchy UI when switching between teams
const [isTeamMembersLoading, setIsTeamMembersLoading] = useState(false);
const membersCache = useMemo(() => new Map<string, TeamMemberList[]>(), []);
const [visibility, setVisibility] = useState(defaultVisibility);
const [isDiscoverable, setIsDiscoverable] = useState(defaultIsDiscoverable);
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
defaultSharedMemberIds
);
@@ -104,6 +119,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
visibility,
sharedFriendIds,
sharedTeamMemberIds,
isDiscoverable,
}
);
@@ -114,11 +130,16 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
setIsLoading(false);
setIsSettingsUpdated(true);
onShareSettingsUpdate({ sharedFriendIds, visibility, sharedTeamMemberIds });
onShareSettingsUpdate({
isDiscoverable,
sharedFriendIds,
visibility,
sharedTeamMemberIds,
});
};
const handleTransferToTeam = useCallback(
async (teamId: string) => {
async (teamId: string, sharedTeamMemberIds: string[]) => {
if (!roadmapId) {
return;
}
@@ -128,6 +149,8 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
{
teamId,
sharedTeamMemberIds,
isDiscoverable,
}
);
@@ -149,7 +172,12 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
wrapperClassName="max-w-lg"
bodyClassName="p-4 flex flex-col"
>
<CopyRoadmapLink roadmapId={roadmapId!} onClose={onClose} />
<ShareSuccess
visibility={visibility}
roadmapId={roadmapId!}
description={description}
onClose={onClose}
/>
</Modal>
);
}
@@ -163,7 +191,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
onClose();
}}
wrapperClassName="max-w-3xl"
bodyClassName="p-4 flex flex-col min-h-[400px]"
bodyClassName="p-4 flex flex-col min-h-[440px]"
>
<div className="mb-4">
<h3 className="mb-1 text-xl font-semibold">Update Sharing Settings</h3>
@@ -195,6 +223,8 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
setSharedFriendIds([]);
setSharedTeamMemberIds([]);
}
setIsDiscoverable(visibility === 'public');
}}
/>
@@ -225,14 +255,6 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
setSharedFriendIds={setSharedFriendIds}
/>
)}
{canTransferRoadmap && (
<TransferToTeamList
teams={teams}
setTeams={setTeams}
selectedTeamId={selectedTeamId}
setSelectedTeamId={setSelectedTeamId}
/>
)}
{/* For Team Roadmap */}
{visibility === 'team' && teamId && (
@@ -240,12 +262,57 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
teamId={teamId}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
members={members}
setMembers={setMembers}
membersCache={membersCache}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
/>
)}
{canTransferRoadmap && (
<>
<TransferToTeamList
teams={teams}
setTeams={setTeams}
selectedTeamId={selectedTeamId}
setSelectedTeamId={setSelectedTeamId}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
onTeamChange={() => {
setSharedTeamMemberIds([]);
}}
/>
{selectedTeamId && (
<>
<hr className="-mx-4 my-4" />
<div className="mb-4">
<ShareTeamMemberList
title="Select who can access this roadmap. You can change this later."
teamId={selectedTeamId!}
sharedTeamMemberIds={sharedTeamMemberIds}
setSharedTeamMemberIds={setSharedTeamMemberIds}
membersCache={membersCache}
isTeamMembersLoading={isTeamMembersLoading}
setIsTeamMembersLoading={setIsTeamMembersLoading}
/>
</div>
</>
)}
</>
)}
</div>
{visibility !== 'me' && (
<>
<hr className="-mx-4 my-4" />
<div className="mb-2">
<DiscoveryCheckbox
isDiscoverable={isDiscoverable}
setIsDiscoverable={setIsDiscoverable}
/>
</div>
</>
)}
<div className="mt-2 flex items-center justify-between gap-1.5">
<button
className="flex items-center justify-center gap-1.5 rounded-md border px-3.5 py-1.5 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-75"
@@ -255,21 +322,28 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
Close
</button>
{canTransferRoadmap ? (
{canTransferRoadmap && (
<UpdateAction
disabled={isUpdateDisabled || isLoading}
disabled={
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
}
onClick={() => {
handleTransferToTeam(selectedTeamId!).then(() => null);
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
() => null
);
}}
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Transfer
</UpdateAction>
) : (
)}
{!canTransferRoadmap && (
<UpdateAction
disabled={isUpdateDisabled || isLoading}
onClick={() => {
handleShareChange({
isDiscoverable,
visibility,
sharedTeamMemberIds:
visibility === 'team' ? sharedTeamMemberIds : [],
@@ -309,3 +383,25 @@ function UpdateAction(props: {
</button>
);
}
type DiscoveryCheckboxProps = {
isDiscoverable: boolean;
setIsDiscoverable: (isDiscoverable: boolean) => void;
};
function DiscoveryCheckbox(props: DiscoveryCheckboxProps) {
const { isDiscoverable, setIsDiscoverable } = props;
return (
<label className="group flex items-center gap-1.5">
<input
type="checkbox"
checked={isDiscoverable}
onChange={(e) => setIsDiscoverable(e.target.checked)}
/>
<span className="text-sm text-gray-500 group-hover:text-gray-700">
Include on discovery page (when launched)
</span>
</label>
);
}

View File

@@ -0,0 +1,132 @@
import { CheckCircle, Copy, Facebook, Linkedin, Twitter } from 'lucide-react';
import { useCopyText } from '../../hooks/use-copy-text';
import { cn } from '../../lib/classname';
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
type ShareSuccessProps = {
roadmapId: string;
onClose: () => void;
visibility: AllowedRoadmapVisibility;
description?: string;
isSharingWithOthers?: boolean;
};
export function ShareSuccess(props: ShareSuccessProps) {
const {
roadmapId,
onClose,
description,
visibility,
isSharingWithOthers = false,
} = props;
const baseUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
const shareLink = `${baseUrl}/r?id=${roadmapId}`;
const { copyText, isCopied } = useCopyText();
const socialShareLinks = [
{
title: 'Twitter',
href: `https://twitter.com/intent/tweet?text=${description}&url=${shareLink}`,
icon: Twitter,
},
{
title: 'Facebook',
href: `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${shareLink}`,
icon: Facebook,
},
{
title: 'Linkedin',
href: `https://www.linkedin.com/sharing/share-offsite/?url=${shareLink}`,
icon: Linkedin,
},
];
return (
<div className="flex grow flex-col justify-center">
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5">
<CheckCircle className="h-14 w-14 text-green-500" />
{isSharingWithOthers ? (
<h3 className="text-xl font-medium">Sharing with Others</h3>
) : (
<h3 className="text-xl font-medium">Sharing Settings Updated</h3>
)}
</div>
<input
type="text"
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none"
value={shareLink}
readOnly
onClick={(e) => {
e.currentTarget.select();
copyText(shareLink);
}}
/>
{isSharingWithOthers ? (
<p className="mt-1 text-sm text-gray-400">
You can share the above link with anyone
</p>
) : (
<p className="mt-1 text-sm text-gray-400">
You can share the above link with anyone who has access
</p>
)}
{visibility === 'public' && (
<>
<div className="-mx-4 mt-4 flex items-center gap-1.5">
<span className="h-px grow bg-gray-300" />
<span className="px-2 text-xs uppercase text-gray-400">Or</span>
<span className="h-px grow bg-gray-300" />
</div>
<div className="mt-4 flex items-center gap-2">
<span className="text-sm text-gray-600">Share with others on</span>
<ul className="flex items-center gap-1.5">
{socialShareLinks.map((socialShareLink) => (
<li key={socialShareLink.title}>
<a
href={socialShareLink.href}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center gap-1.5 rounded-md border bg-gray-50 text-sm text-gray-700 hover:bg-gray-100 focus:outline-none"
>
<socialShareLink.icon className="h-4 w-4" />
</a>
</li>
))}
</ul>
</div>
</>
)}
<div className="mt-4 flex flex-col items-center justify-end gap-2">
<button
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
isCopied && 'bg-green-300 text-green-800'
)}
disabled={isCopied}
onClick={() => {
copyText(shareLink);
}}
>
<Copy className="h-3.5 w-3.5 stroke-[2.5]" />
{isCopied ? 'Copied' : 'Copy URL'}
</button>
<button
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
)}
onClick={onClose}
>
Close
</button>
</div>
</div>
);
}

View File

@@ -33,27 +33,31 @@ export interface TeamMemberList extends TeamMemberDocument {
type ShareTeamMemberListProps = {
teamId: string;
setMembers: (members: TeamMemberList[]) => void;
members: TeamMemberList[];
title?: string;
sharedTeamMemberIds: string[];
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
membersCache: Map<string, TeamMemberList[]>;
isTeamMembersLoading: boolean;
setIsTeamMembersLoading: (isLoading: boolean) => void;
};
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
const {
setMembers,
members,
teamId,
title = 'Select Members',
sharedTeamMemberIds,
setSharedTeamMemberIds,
teamId,
membersCache,
isTeamMembersLoading: isLoading,
setIsTeamMembersLoading: setIsLoading,
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
async function loadTeamMembers() {
if (members?.length > 0) {
if (membersCache.has(teamId)) {
return;
}
@@ -67,21 +71,23 @@ export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
return;
}
setMembers(response);
const joinedMembers =
response?.filter((member) => member.status === 'joined') || [];
membersCache.set(teamId, joinedMembers);
}
useEffect(() => {
loadTeamMembers().finally(() => {
setIsLoading(false);
});
}, []);
}, [teamId]);
const loadingMembers = isLoading && (
<ul className="mt-2 grid grid-cols-3 gap-2.5">
{[...Array(3)].map((_, idx) => (
<li
key={idx}
className="flex min-h-[62px] animate-pulse items-center gap-2 rounded-md border p-2"
className="flex min-h-[66px] animate-pulse items-center gap-2 rounded-md border p-2"
>
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
<div className="inline-grid w-full">
@@ -93,11 +99,13 @@ export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
</ul>
);
const members = membersCache.get(teamId) || [];
return (
<>
{(members.length > 0 || isLoading) && (
<div className="flex items-center justify-between gap-2">
<p className="text-sm">Select Members</p>
<p className="text-sm">{title}</p>
<label className="flex items-center gap-2">
<input

View File

@@ -11,10 +11,22 @@ type TransferToTeamListProps = {
selectedTeamId: string | null;
setSelectedTeamId: (teamId: string | null) => void;
isTeamMembersLoading: boolean;
setIsTeamMembersLoading: (isLoading: boolean) => void;
onTeamChange: (teamId: string | null) => void;
};
export function TransferToTeamList(props: TransferToTeamListProps) {
const { teams, setTeams, selectedTeamId, setSelectedTeamId } = props;
const {
teams,
setTeams,
selectedTeamId,
setSelectedTeamId,
isTeamMembersLoading,
setIsTeamMembersLoading,
onTeamChange,
} = props;
const toast = useToast();
@@ -73,11 +85,17 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
<li key={team._id}>
<button
className={cn(
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
isSelected && 'border-gray-500 bg-gray-100 text-black'
)}
disabled={isTeamMembersLoading}
onClick={() => {
setSelectedTeamId(team._id);
if (isSelected) {
setSelectedTeamId(null);
} else {
setSelectedTeamId(team._id);
}
onTeamChange(team._id);
}}
>
<img

View File

@@ -0,0 +1,16 @@
type TeamAnnouncementProps = {};
export function TeamAnnouncement(props: TeamAnnouncementProps) {
return (
<a
className="rounded-md border border-dashed border-purple-700 px-3 py-1.5 text-purple-400 transition-colors hover:border-gray-700 hover:text-white"
href="/teams"
>
<span className="relative -top-[0.5px] mr-1 text-xs font-semibold uppercase text-white">
New
</span>{' '}
<span className={'hidden sm:inline'}>Announcing roadmaps for teams. <span className='font-semibold'>Learn more!</span></span>
<span className={'inline sm:hidden'}>Announcing roadmaps for teams!</span>
</a>
);
}

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { cn } from '../../lib/classname.ts';
import { isLoggedIn } from '../../lib/jwt.ts';
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
const demoItems = [
{
title: 'Roadmap Editor',
description:
'<span class="font-semibold">Powerful editor</span> to create custom roadmaps and other trackable documents',
image: '/images/team-promo/roadmap-editor.png',
},
{
title: 'Invite Members',
description:
'Invite your <span class="font-semibold">team members and assign roles</span>',
image: '/images/team-promo/invite-members.png',
},
{
title: 'Track Progress',
description:
'You and your team can <span class="font-semibold">track progress</span> on the roadmaps',
image: '/images/team-promo/update-progress.png',
},
{
title: 'Team Dashboard',
description:
'Keep an eye on the team progress through <span class="font-semibold">team dashboards</span>',
image: '/images/team-promo/team-dashboard.png',
},
{
title: 'Roadmaps and Documents',
description:
'Create as many <span class="font-semibold">roadmaps or trackable documents</span> as you want',
image: '/images/team-promo/many-roadmaps.png',
},
{
title: 'Community Roadmaps',
description:
'Create custom roadmaps or customize <span class="font-semibold">community roadmaps</span> to fit your needs',
image: '/images/team-promo/our-roadmaps.png',
},
{
title: 'Sharing Settings',
description:
'Share a roadmap or trackable document with <span class="font-semibold">everyone or specific people</span>',
image: '/images/team-promo/sharing-settings.png',
},
{
title: 'More Coming Soon!',
description:
'<span class="font-semibold">We have a lot more coming soon!</span>',
},
];
export function TeamDemo() {
const [hasViewed, setHasViewed] = useState<number[]>([0]);
const [activeItem, setActiveItem] = useState(demoItems[0]);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
useEffect(() => {
setIsAuthenticated(isLoggedIn());
}, []);
return (
<div className="hidden border-t py-12 sm:block">
<div className="container">
<h2 className="mb-2 text-3xl font-bold">See how it works</h2>
<p>Here is a sneak peek of what you can do today (more coming soon!)</p>
<div className="relative mt-7 flex flex-row items-center gap-2.5">
{demoItems.map((item, counter) => {
const isActive = item === activeItem;
const hasAlreadyViewed = hasViewed.includes(counter);
if (!isActive) {
return (
<span key={item.title} className="relative flex items-center">
<span
onClick={() => {
setHasViewed([...hasViewed, counter]);
setActiveItem(item);
}}
className={cn('z-50 cursor-pointer rounded-full p-[6px]', {
'bg-black': item === activeItem,
'bg-gray-300 hover:bg-gray-400': item !== activeItem,
})}
/>
{!hasAlreadyViewed && (
<span className="pointer-events-none absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 opacity-75"></span>
)}
</span>
);
}
return (
<span
key={item.title}
className=" rounded-full bg-black px-3 py-0.5 text-sm text-white"
>
{activeItem.title}
</span>
);
})}
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-gray-300">
<div className="p-3">
<p
className="text-base text-black"
dangerouslySetInnerHTML={{ __html: activeItem.description }}
/>
</div>
{activeItem.image && (
<img
className="rounded-b-xl border-t"
src={activeItem.image}
alt=""
/>
)}
{!activeItem.image && (
<div className="bg-gray-50 py-4 pl-3">
<p className="mb-3">
Register your team now and help us shape the future of teams in
roadmap.sh!
</p>
<div className="flex flex-row items-center gap-2">
<a
onClick={() => {
fireTeamCreationClick();
if (isAuthenticated) {
return;
}
localStorage.setItem('authRedirect', '/team/new');
}}
href={isAuthenticated ? '/team/new' : '/signup'}
className="inline-flex items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-base font-medium text-white hover:bg-purple-700"
>
Create your Team
</a>
{!isAuthenticated && (
<span className="ml-1 text-base">
or &nbsp;
<a
onClick={() => {
fireTeamCreationClick();
localStorage.setItem('authRedirect', '/team/new');
}}
href="/login"
className="text-purple-600 underline hover:text-purple-700"
>
Login to your account
</a>
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { CheckCircle } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt.ts';
import { useEffect, useState } from 'react';
const featureList = [
'Create custom roadmaps for your team',
"Plan, track and document your team's skills and growth",
'Invite your team members',
"Get insights on your team's skills and growth",
];
export function fireTeamCreationClick() {
window.fireEvent({
category: 'FeatureClick',
action: `Pages / Teams`,
label: 'Create your Team',
});
}
export function TeamHeroBanner() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
useEffect(() => {
setIsAuthenticated(isLoggedIn());
}, []);
return (
<div className="bg-white py-8 lg:py-12">
<div className="container">
<div className="flex flex-row items-center justify-start text-left lg:justify-between">
<div className="flex flex-grow flex-col">
<h1 className="mb-0.5 text-2xl font-bold sm:mb-2.5 sm:text-4xl lg:mb-4 lg:text-5xl">
Roadmaps for Teams
</h1>
<p className="mb-4 text-base leading-normal text-gray-600 sm:mb-0 sm:leading-none lg:text-lg">
Train, plan and track your team's skills and career growth.
</p>
<ul className="mb-4 mt-0 hidden text-sm leading-7 sm:mb-4 sm:mt-4 sm:flex sm:flex-col lg:mb-6 lg:mt-6 lg:text-base lg:leading-8">
{featureList.map((feature, index) => (
<li key={feature}>
<CheckCircle className="hidden h-6 w-6 text-green-500 sm:inline-block" />
<span className="ml-0 sm:ml-2">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<a
onClick={() => {
fireTeamCreationClick();
}}
href={isAuthenticated ? '/team/new' : '/signup'}
className="flex w-full items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 sm:w-auto sm:text-base"
>
Create your Team
</a>
{!isAuthenticated && (
<>
<span className="ml-1 hidden text-base sm:inline">
or &nbsp;
<a
href="/login"
onClick={() => {
fireTeamCreationClick();
localStorage.setItem('authRedirect', '/team/new');
}}
className="text-purple-600 underline hover:text-purple-700"
>
Login to your account
</a>
</span>
<a
href="/login"
onClick={() => {
fireTeamCreationClick();
localStorage.setItem('authRedirect', '/team/new');
}}
className="flex w-full items-center justify-center rounded-lg border border-purple-600 px-5 py-2 text-base text-sm font-medium text-purple-600 hover:bg-blue-700 sm:hidden sm:text-base"
>
Login to your account
</a>
</>
)}
</div>
</div>
<img
alt={'team roadmaps'}
className="hidden h-64 md:block lg:h-80"
src="/images/team-promo/hero-img.png"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
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';
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
import { useEffect, useState } from 'react';
export function TeamPricing() {
const { isCopied, copyText } = useCopyText();
const teamEmail = 'teams@roadmap.sh';
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
useEffect(() => {
setIsAuthenticated(isLoggedIn());
}, []);
return (
<div className="border-t py-4 sm:py-8 md:py-12">
<div className="container">
<h2 className="mb-1 text-xl font-bold sm:mb-1.5 sm:text-2xl md:mb-2 md:text-3xl">
Beta Pricing
</h2>
<p className="mb-4 text-base text-gray-600 sm:mb-8 sm:text-lg">
We are currently in public beta and are offering free access to all
features.
</p>
<div className="flex flex-col gap-6 sm:flex-row sm:gap-4">
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-purple-500">
<div className="px-8 pb-2 pt-5 text-center sm:pt-4">
<h3 className="mb-1 text-2xl font-bold">Free</h3>
<p className="text-sm text-gray-500">No credit card required</p>
<p className="flex items-start justify-center gap-1 py-6 text-3xl">
<span className="text-base text-gray-600">$</span>
<span className="text-5xl font-bold">0</span>
</p>
<a
onClick={() => {
fireTeamCreationClick();
if (isAuthenticated) {
return;
}
localStorage.setItem('redirect', '/team/new');
}}
href={isAuthenticated ? '/team/new' : '/signup'}
className="block rounded-md bg-purple-600 px-6 py-2 text-center text-sm font-medium leading-6 text-white shadow transition hover:bg-gray-700 hover:shadow-lg focus:outline-none"
>
{isAuthenticated ? 'Create your Team' : 'Sign up for free'}
</a>
</div>
<div className="flex w-full flex-col gap-1 border-t px-8 py-5 text-center sm:py-3">
<p className="font-semibold text-black">All the features</p>
<p className="text-gray-600">Roles and Permissions</p>
<p className="text-gray-600">Custom Roadmaps</p>
<p className="text-gray-600">Sharing Options</p>
<p className="text-gray-600">Progress Tracking</p>
<p className="text-gray-600">Team Insights</p>
<p className="text-gray-600">Onboarding support</p>
</div>
</div>
<div className="flex flex-grow flex-col items-center justify-center rounded-md border border-gray-300 py-8">
<img
alt={'waving hand'}
src={'/images/team-promo/contact.png'}
className="mb-3 h-40"
/>
<p className="mb-2 font-medium text-gray-500">
Questions? We are here to help!
</p>
<p className="text-gray-600">
<button
onClick={() => {
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',
)}
>
{teamEmail}
<Copy
className="relative top-[1px] ml-2 inline-block text-black transition-opacity"
size={16}
/>
<span
className={cn(
'absolute bottom-0 left-0 right-0 flex items-center justify-center bg-black text-white transition-all',
{
'top-full': !isCopied,
'top-0': isCopied,
},
)}
>
Email copied!
</span>
</button>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
const toolsList = [
{
imageUrl: '/images/team-promo/growth-plans.png',
title: 'Growth plans',
description: 'Prepare shared or individual growth plans for members.',
},
{
imageUrl: '/images/team-promo/progress-tracking.png',
title: 'Progress tracking',
description: 'Track and compare the progress of team members.',
},
{
imageUrl: '/images/team-promo/onboarding.png',
title: 'Onboarding',
description: 'Prepare onboarding plans for new team members.',
},
{
imageUrl: '/images/team-promo/team-insights.png',
title: 'Team insights',
description: 'Get insights about your team skills, progress and more.',
},
{
imageUrl: '/images/team-promo/skill-gap.png',
title: 'Skill gap analysis',
description: 'Understand the skills of your team and identify gaps.',
},
{
imageUrl: '/images/team-promo/documentation.png',
title: 'Documentation',
description: 'Create and share visual team documentation.',
},
];
export function TeamTools() {
return (
<div className="py-4 sm:py-8 md:py-12 border-t">
<div className="container">
<h2 className="mb-1 sm:mb-1.5 md:mb-2 text-xl sm:text-2xl md:text-3xl font-bold">Track and guide your teams knowledge</h2>
<p className='text-sm md:text-base'>
Individual and team level growth plans, progress tracking, skill gap analysis, team insights and more.
</p>
<div className="mt-3 sm:mt-5 md:mt-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 sm:gap-4">
{toolsList.map((tool) => {
return (
<div className="rounded-md sm:rounded-xl border p-2 sm:p-5 text-left sm:text-center md:text-left">
<img
alt={tool.title}
src={tool.imageUrl}
className="mb-5 h-48 hidden sm:block mx-auto md:mx-0"
/>
<h3 className="mb-0.5 sm:mb-2 text-lg sm:text-2xl font-bold">{tool.title}</h3>
<p className='text-sm sm:text-base'>{tool.description}</p>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -44,6 +44,8 @@ export interface TeamMemberItem extends TeamMemberDocument {
hasProgress: boolean;
}
const MAX_MEMBER_COUNT = 100;
export function TeamMembersPage() {
const { t: teamId } = getUrlParams();
@@ -307,7 +309,7 @@ export function TeamMembersPage() {
{canManageCurrentTeam && (
<div className="mt-4">
<button
disabled={teamMembers.length >= 25}
disabled={teamMembers.length >= MAX_MEMBER_COUNT}
onClick={() => setIsInvitingMember(true)}
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
>
@@ -316,7 +318,7 @@ export function TeamMembersPage() {
</div>
)}
{teamMembers.length >= 25 && canManageCurrentTeam && (
{teamMembers.length >= MAX_MEMBER_COUNT && canManageCurrentTeam && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
You have reached the maximum number of members in a team. Please reach
out to us if you need more.

View 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>
);
}

View File

@@ -16,13 +16,8 @@ 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';
import {replaceChildren} from "../../lib/dom.ts";
export type ProgressMapProps = {
member: TeamMember;
@@ -49,7 +44,6 @@ export function MemberProgressModal(props: ProgressMapProps) {
onShowMyProgress,
teamId,
onClose,
isCustomResource,
} = props;
const user = useAuth();
const isCurrentUser = user?.email === member.email;
@@ -70,12 +64,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,30 +86,14 @@ 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, {
fontURL: '/fonts/balsamiq.woff2',
});
}
const svg: SVGElement | null = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
containerEl.current?.replaceChildren(svg);
replaceChildren(containerEl.current!, svg);
// containerEl.current?.replaceChildren(svg);
}
useKeydown('Escape', () => {
@@ -215,29 +187,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 groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
const topicId = groupId.replace(/^\d+-/, '');
if (targetGroup.classList.contains('removed')) {
e.preventDefault();
@@ -255,29 +209,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 groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
const topicId = groupId.replace(/^\d+-/, '');
if (targetGroup.classList.contains('removed')) {
return;
@@ -321,136 +257,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'}

View 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>
</>
);
}

View File

@@ -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}

View File

@@ -27,6 +27,7 @@ import {
import { RoadmapActionDropdown } from './RoadmapActionDropdown';
import { UpdateTeamResourceModal } from '../CreateTeam/UpdateTeamResourceModal';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import { cn } from '../../lib/classname';
export function TeamRoadmaps() {
const { t: teamId } = getUrlParams();
@@ -311,6 +312,7 @@ export function TeamRoadmaps() {
const shareSettingsModal = selectedResource && (
<ShareOptionsModal
description={selectedResource.description!}
visibility={selectedResource.visibility!}
sharedTeamMemberIds={selectedResource.sharedTeamMemberIds!}
sharedFriendIds={selectedResource.sharedFriendIds!}
@@ -427,7 +429,12 @@ export function TeamRoadmaps() {
return (
<div
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
className={cn(
'grid grid-cols-1 p-2.5',
canManageCurrentTeam
? 'sm:grid-cols-[auto_172px]'
: 'sm:grid-cols-[auto_110px]'
)}
key={resourceConfig.resourceId}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
@@ -478,6 +485,18 @@ export function TeamRoadmaps() {
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
{canManageCurrentTeam && (
<a
href={editorLink}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-800 bg-gray-900 px-2.5 py-1.5 text-xs text-white hover:bg-gray-800 focus:outline-none'
}
target={'_blank'}
>
<PenSquare className="inline-block h-4 w-4" />
Edit
</a>
)}
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import CloseIcon from '../../icons/close.svg';
import SpinnerIcon from '../../icons/spinner.svg';
@@ -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,
);
}
@@ -184,6 +184,10 @@ export function TopicDetail(props: TopicDetailProps) {
});
});
useEffect(() => {
if (isActive) topicRef?.current?.focus();
}, [isActive]);
if (!isActive) {
return null;
}
@@ -194,7 +198,8 @@ export function TopicDetail(props: TopicDetailProps) {
<div className={'relative z-50'}>
<div
ref={topicRef}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
tabIndex={0}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 focus:outline-0 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex w-full justify-center">
@@ -279,7 +284,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() +

View 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>
)
}

View 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>
);
}

View File

@@ -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}

View 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>
);
}

View File

@@ -67,7 +67,7 @@ Unlike browser cache which serves a single user, proxy caches may serve hundreds
A Reverse proxy cache or surrogate cache is implemented close to the origin servers in order to reduce the load on the server. Unlike proxy caches which are implemented by ISPs etc to reduce the bandwidth usage in a network, surrogates or reverse proxy caches are implemented near the origin servers by the server administrators to reduce the load on the server.
![Reverse Proxy Cache](http://i.imgur.com/Eg4Cru3.png)
![Reverse Proxy Cache](https://i.imgur.com/Eg4Cru3.png)
Although you can control the reverse proxy caches (since it is implemented by you on your server) you can not avoid or control browser and proxy caches. And if your website is not configured to use these caches properly, it will still be cached using whatever defaults are set on these caches.

View File

@@ -0,0 +1,27 @@
Let's see how we can use the `alert`, `prompt` and `confirm` functions to interact with the user.
## alert()
The `alert()` method displays an alert box with a specified message and an OK button.
```js
alert('Hello World!');
```
## prompt()
The `prompt()` method displays a dialog box that prompts the visitor for input. A prompt box is often used if you want the user to input a value before entering a page. The `prompt()` method returns the input value if the user clicks OK. If the user clicks Cancel, the method returns `null`.
```js
const name = prompt('What is your name?');
console.log(name);
```
## confirm()
The `confirm()` method displays a dialog box with a specified message, along with an OK and a Cancel button. This is often used to confirm or verify something from the user.
```js
const result = confirm('Are you sure?');
console.log(result); // true/false
```

Some files were not shown because too many files have changed in this diff Show More