Compare commits
92 Commits
chore/java
...
feat/versi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
985d2e9692 | ||
|
|
34c2b932da | ||
|
|
24123bc3e5 | ||
|
|
5a1be434cb | ||
|
|
ff3ebed493 | ||
|
|
7da244fe10 | ||
|
|
cf78628c0c | ||
|
|
498e03720f | ||
|
|
5c69b05470 | ||
|
|
2a76ebfbbf | ||
|
|
ba1f9ef9d0 | ||
|
|
4a864175cc | ||
|
|
d3cfd50831 | ||
|
|
309cf3d6d9 | ||
|
|
4f3b891e45 | ||
|
|
47f548a0e4 | ||
|
|
a988ecc4ab | ||
|
|
d2343f4b15 | ||
|
|
c723070057 | ||
|
|
3a0e588530 | ||
|
|
d46cf26812 | ||
|
|
b06e82de5f | ||
|
|
d65ecac777 | ||
|
|
c46d962803 | ||
|
|
bd4e7ea3d0 | ||
|
|
252b083a48 | ||
|
|
abbeb717d1 | ||
|
|
485ca9dd8f | ||
|
|
c3315fb41e | ||
|
|
6ed436674f | ||
|
|
76c6c4dc1f | ||
|
|
cb56e85651 | ||
|
|
dcf740e275 | ||
|
|
16662ed699 | ||
|
|
6f9fe361ae | ||
|
|
036b34c6f3 | ||
|
|
93c2043f23 | ||
|
|
d2da3c8621 | ||
|
|
4aa8f15c07 | ||
|
|
ceb4c3b95d | ||
|
|
7ec5e30b51 | ||
|
|
e5e0a7c8c5 | ||
|
|
90f3ffe270 | ||
|
|
ce47a7433e | ||
|
|
21b8358683 | ||
|
|
e1751b105f | ||
|
|
e43bea7c40 | ||
|
|
5fa669aec2 | ||
|
|
4b8f868b2b | ||
|
|
a0743a8272 | ||
|
|
2cae13c090 | ||
|
|
0bf287f1d6 | ||
|
|
d7d819b4b3 | ||
|
|
29cff6a6f8 | ||
|
|
044df81b7a | ||
|
|
3151ee5021 | ||
|
|
e6ce9f40ee | ||
|
|
3b5e3c44f9 | ||
|
|
c286e0a6f8 | ||
|
|
3bebe0c1de | ||
|
|
9845fe624a | ||
|
|
4b2b2ebe8c | ||
|
|
82c2aaacc3 | ||
|
|
6d1edb76c7 | ||
|
|
5d57d5baaf | ||
|
|
d31d626c61 | ||
|
|
71bf34e683 | ||
|
|
93a91b1d9b | ||
|
|
18c8bd14b2 | ||
|
|
e34695e334 | ||
|
|
8310671123 | ||
|
|
d45c8f9cb2 | ||
|
|
573263ed74 | ||
|
|
f27aa58ac3 | ||
|
|
518cf4ce73 | ||
|
|
7bde0b3f44 | ||
|
|
4b6dcb3a37 | ||
|
|
c50200bfe7 | ||
|
|
5ffb9fad9f | ||
|
|
dd7d312aa1 | ||
|
|
81447f6b43 | ||
|
|
fe711f498d | ||
|
|
c65f12fcb8 | ||
|
|
cab075bf5b | ||
|
|
685021493c | ||
|
|
482cf64bf5 | ||
|
|
9051e22476 | ||
|
|
1b538b399f | ||
|
|
05673087c5 | ||
|
|
5256df9c07 | ||
|
|
ddf8884501 | ||
|
|
05492b60ee |
@@ -1,2 +1,3 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_API_URL=https://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
|
||||
@@ -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
|
||||
|
||||
9
.github/workflows/deploy.yml
vendored
@@ -4,21 +4,23 @@ on:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh"
|
||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PAT: ${{ secrets.PAT }}
|
||||
CI: true
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
|
||||
- name: Prepare Draw Repository
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
@@ -27,6 +29,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Generate meta and build
|
||||
run: |
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
touch ./dist/.nojekyll
|
||||
echo 'roadmap.sh' > ./dist/CNAME
|
||||
|
||||
2
.github/workflows/update-deps.yml
vendored
@@ -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
|
||||
|
||||
4
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.idea
|
||||
.temp
|
||||
|
||||
# build output
|
||||
dist/
|
||||
@@ -27,3 +28,6 @@ pnpm-debug.log*
|
||||
/playwright/.cache/
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
/editor/*
|
||||
!/editor/readonly-editor.tsx
|
||||
3
.npmrc
@@ -1 +1,2 @@
|
||||
auto-install-peers=true
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
@@ -13,6 +13,6 @@ module.exports = {
|
||||
],
|
||||
plugins: [
|
||||
require.resolve('prettier-plugin-astro'),
|
||||
require('prettier-plugin-tailwindcss'),
|
||||
'prettier-plugin-tailwindcss',
|
||||
],
|
||||
};
|
||||
|
||||
14
editor/readonly-editor.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
11028
package-lock.json
generated
Normal file
60
package.json
@@ -16,48 +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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"tailwindcss": "^3.3.3"
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
2388
pnpm-lock.yaml
generated
BIN
public/images/partners/nginx.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/roadmap-editor.jpeg
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
public/images/team-promo/contact.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/images/team-promo/documentation.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
public/images/team-promo/growth-plans.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
public/images/team-promo/hero-img.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/team-promo/hero.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/images/team-promo/invite-members.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
public/images/team-promo/many-roadmaps.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
public/images/team-promo/onboarding.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
public/images/team-promo/our-roadmaps.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/images/team-promo/progress-tracking.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
public/images/team-promo/roadmap-editor.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/images/team-promo/sharing-settings.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
public/images/team-promo/skill-gap.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/images/team-promo/team-dashboard.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
public/images/team-promo/team-insights.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
public/images/team-promo/update-progress.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 561 KiB |
13
readme.md
@@ -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) · [Best Practices](https://roadmap.sh/best-practices) · [Questions](https://roadmap.sh/questions)
|
||||
|
||||

|
||||
|
||||
@@ -67,13 +67,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)
|
||||
|
||||

|
||||
|
||||
## Share with the community
|
||||
|
||||
14
renderer/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function Renderer(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>
|
||||
);
|
||||
}
|
||||
5
renderer/renderer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function renderFlowJSON(data: any, options?: any) {
|
||||
console.warn("renderFlowJSON is not implemented");
|
||||
console.warn("run the following command to generate the renderer:");
|
||||
console.warn("> npm run generate-renderer");
|
||||
}
|
||||
32
scripts/generate-renderer.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
-#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# ignore cloning if .temp/web-draw already exists
|
||||
if [ ! -d ".temp/web-draw" ]; then
|
||||
mkdir -p .temp
|
||||
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
|
||||
fi
|
||||
|
||||
rm -rf editor
|
||||
mkdir editor
|
||||
|
||||
# copy the files at /src/editor/* to /editor
|
||||
# while replacing any existing files
|
||||
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 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
|
||||
mv temp "$file"
|
||||
echo "Added @ts-nocheck to $file"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# ignore the worktree changes for the editor directory
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
|
||||
import { Map } from 'lucide-react';
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
@@ -26,10 +27,21 @@ const sidebarLinks = [
|
||||
href: '/account/friends',
|
||||
title: 'Friends',
|
||||
id: 'friends',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'users',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
id: 'roadmaps',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'users',
|
||||
classes: 'h-4 w-4',
|
||||
component: Map,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -85,7 +97,7 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||
Teams
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
@@ -100,10 +112,16 @@ const sidebarLinks = [
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.icon.component ? (
|
||||
<sidebarLink.icon.component
|
||||
className={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
) : (
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
)}
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
@@ -136,21 +154,25 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.icon.component ? (
|
||||
<sidebarLink.icon.component
|
||||
className={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
) : (
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
)}
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
{sidebarLink.isNew &&
|
||||
sidebarLink.id !== 'friends' &&
|
||||
!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 />
|
||||
|
||||
@@ -5,6 +5,17 @@ import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
type ProgressResponse = {
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
isCustomResource: boolean;
|
||||
};
|
||||
|
||||
export type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
@@ -13,24 +24,9 @@ export type ActivityResponse = {
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
roadmaps: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
total: number;
|
||||
skipped: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
bestPractices: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
roadmaps: ProgressResponse[];
|
||||
bestPractices: ProgressResponse[];
|
||||
customs: ProgressResponse[];
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
@@ -110,7 +106,8 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
key={roadmap.id}
|
||||
key={roadmap.id}
|
||||
isCustomResource={roadmap.isCustomResource}
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
@@ -137,6 +134,8 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((bestPractice) => (
|
||||
<ResourceProgress
|
||||
isCustomResource={bestPractice.isCustomResource}
|
||||
key={bestPractice.id}
|
||||
doneCount={bestPractice.done || 0}
|
||||
totalCount={bestPractice.total || 0}
|
||||
learningCount={bestPractice.learning || 0}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getRelativeTimeString } from '../../lib/date';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
|
||||
import { useState } from 'react';
|
||||
import { getUser } from '../../lib/jwt';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@@ -15,14 +16,17 @@ type ResourceProgressType = {
|
||||
skippedCount: number;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true } = props;
|
||||
const { showClearButton = true, isCustomResource } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const userId = getUser()?.id;
|
||||
|
||||
const {
|
||||
updatedAt,
|
||||
resourceType,
|
||||
@@ -52,8 +56,8 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
|
||||
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
@@ -62,11 +66,15 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
let url =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}`
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
if (isCustomResource) {
|
||||
url = `/r?id=${resourceId}`;
|
||||
}
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
|
||||
@@ -112,6 +120,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
<ProgressShareButton
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
className="text-xs font-normal"
|
||||
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
||||
checkIconClassName="w-2.5 h-2.5"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { OptionType, SearchSelector } from './SearchSelector';
|
||||
import { type OptionType, SearchSelector } from './SearchSelector';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { httpPut } from '../lib/http';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -36,6 +36,7 @@ function handleGuest() {
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/roadmaps',
|
||||
'/account/road-card',
|
||||
'/account/friends',
|
||||
'/account',
|
||||
@@ -72,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||
import { NotDropdown } from './NotDropdown';
|
||||
import { Map, Shapes } from 'lucide-react';
|
||||
import type {
|
||||
AllowedRoadmapVisibility,
|
||||
RoadmapDocument,
|
||||
} from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
isCustomResource: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
removed: string[];
|
||||
topics?: number;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
teamId: string;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
teamResources: TeamResourceConfig;
|
||||
setTeamResources: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
const { teamId, teamResources = [], setTeamResources } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState<boolean>(false);
|
||||
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong. Please try again!');
|
||||
setError(error.message || 'Something went wrong. Please try again!');
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +88,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
@@ -106,13 +122,25 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAllRoadmaps().finally();
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
}, []);
|
||||
|
||||
function handleCustomRoadmapCreated(roadmap: RoadmapDocument) {
|
||||
const { _id: roadmapId } = roadmap;
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changingRoadmapId && (
|
||||
@@ -121,9 +149,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResources}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
teamResources.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
@@ -131,7 +159,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
@@ -145,72 +173,170 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<NotDropdown
|
||||
<div className="my-3 flex items-center gap-4">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={teamId}
|
||||
onClose={() => setIsCreatingRoadmap(false)}
|
||||
onCreated={(roadmap: RoadmapDocument) => {
|
||||
handleCustomRoadmapCreated(roadmap);
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
|
||||
onClick={() => {
|
||||
setShowSelectRoadmapModal(true);
|
||||
}}
|
||||
selectedCount={teamResourceConfig.length}
|
||||
singularName={'roadmap'}
|
||||
pluralName={'roadmaps'}
|
||||
/>
|
||||
>
|
||||
<Map className="h-4 w-4 stroke-[2.5]" />
|
||||
Pick from our roadmaps
|
||||
</button>
|
||||
|
||||
<span className="text-base text-gray-400">or</span>
|
||||
|
||||
<button
|
||||
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
<Shapes className="h-4 w-4 stroke-[2.5]" />
|
||||
Create Custom Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
||||
No roadmaps selected.
|
||||
</p>
|
||||
{!teamResources.length && (
|
||||
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-lg border">
|
||||
<Map className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className={'text-lg font-semibold'}>No roadmaps selected.</p>
|
||||
<p className={'text-base text-gray-400'}>
|
||||
Pick from{' '}
|
||||
<span
|
||||
onClick={() => setShowSelectRoadmapModal(true)}
|
||||
className="cursor-pointer underline"
|
||||
>
|
||||
our roadmaps
|
||||
</span>{' '}
|
||||
or{' '}
|
||||
<span
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
className="cursor-pointer underline"
|
||||
>
|
||||
create a new one
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
{teamResources.length > 0 && (
|
||||
<div className="mb-3 grid grid-cols-1 flex-wrap gap-2.5 sm:grid-cols-3">
|
||||
{teamResources.map(
|
||||
({
|
||||
isCustomResource,
|
||||
title: roadmapTitle,
|
||||
resourceId,
|
||||
removed: removedTopics,
|
||||
topics,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
|
||||
key={resourceId}
|
||||
>
|
||||
<div className={'w-full flex-grow px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-snug text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 || (topics && topics > 0) ? (
|
||||
<span className={'text-xs leading-none text-gray-400'}>
|
||||
{isCustomResource ? (
|
||||
<>
|
||||
Custom · {topics} topic
|
||||
{topics && topics > 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
{isCustomResource
|
||||
? 'Placeholder roadmap.'
|
||||
: 'No changes made ..'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-none text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
{removingRoadmapId === resourceId && (
|
||||
<div
|
||||
className={
|
||||
'flex w-full items-center justify-end p-3 text-sm'
|
||||
}
|
||||
>
|
||||
<span className="text-xs text-gray-500">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={() => onRemove(resourceId)}
|
||||
className="mx-0.5 text-red-500 underline underline-offset-1"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setRemovingRoadmapId('')}
|
||||
className="text-red-500 underline underline-offset-1"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(!removingRoadmapId || removingRoadmapId !== resourceId) && (
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => {
|
||||
if (isCustomResource) {
|
||||
window.open(
|
||||
`${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${resourceId}`,
|
||||
'_blank'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setChangingRoadmapId(resourceId);
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => setRemovingRoadmapId(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => setChangingRoadmapId(resourceId)}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => onRemove(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -100,12 +100,13 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
{roleBasedRoadmaps.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
const isSelected = !!teamResourceConfig?.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
key={roadmap.id}
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
@@ -131,6 +132,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
key={roadmap.id}
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
|
||||
@@ -10,13 +10,15 @@ export const validTeamTypes = [
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon.src,
|
||||
description: 'Track the skills and learning progress of the tech team at your company',
|
||||
description:
|
||||
'Track the skills and learning progress of the tech team at your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon.src,
|
||||
description: 'Invite your friends or course-mates and track your learning progress together',
|
||||
description:
|
||||
'Invite your friends or course-mates and track your learning progress together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -70,10 +72,11 @@ export function Step0(props: Step0Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col sm:flex-row gap-3'}>
|
||||
<div className={'flex flex-col gap-3 sm:flex-row'}>
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
||||
key={validTeamType.value}
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
|
||||
validTeamType.value == selectedTeamType
|
||||
? 'border-gray-400 bg-gray-100'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
@@ -81,6 +84,7 @@ export function Step0(props: Step0Props) {
|
||||
onClick={() => setSelectedTeamType(validTeamType.value)}
|
||||
>
|
||||
<img
|
||||
key={validTeamType.value}
|
||||
alt={validTeamType.label}
|
||||
src={validTeamType.icon}
|
||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||
@@ -90,7 +94,7 @@ export function Step0(props: Step0Props) {
|
||||
<span className="mb-2 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 leading-[21px]">
|
||||
<span className="text-sm leading-[21px] text-gray-500">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
@@ -100,11 +104,11 @@ export function Step0(props: Step0Props) {
|
||||
{/*Error message*/}
|
||||
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||
<a
|
||||
href="/account"
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500 text-center'
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-center text-red-500'
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -221,11 +221,11 @@ export function Step1(props: Step1Props) {
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
<option value="">
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option value={size}>{size} people</option>
|
||||
<option key={size} value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,9 @@ export function Step2(props: Step2Props) {
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-1 md:mb-1.5 text-lg md:text-2xl font-bold">Select Roadmaps</h2>
|
||||
<h2 className="mb-1 text-lg font-bold md:mb-1.5 md:text-2xl">
|
||||
Select Roadmaps
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
You can always add and customize your roadmaps later.
|
||||
</p>
|
||||
@@ -25,12 +27,12 @@ export function Step2(props: Step2Props) {
|
||||
|
||||
<RoadmapSelector
|
||||
teamId={team._id!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
teamResources={teamResourceConfig}
|
||||
setTeamResources={setTeamResourceConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -46,8 +48,9 @@ export function Step2(props: Step2Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={teamResourceConfig.length !== 0}
|
||||
className={
|
||||
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto'
|
||||
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto disabled:opacity-50 disabled:pointer-events-none'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
|
||||
@@ -178,8 +178,9 @@ export function Step3(props: Step3Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={users.filter((u) => u.email).length !== 0}
|
||||
className={
|
||||
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
|
||||
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black disabled:opacity-50 disabled:pointer-events-none'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
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 { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
teamId: string;
|
||||
@@ -40,8 +38,6 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
const [removedItems, setRemovedItems] =
|
||||
useState<string[]>(defaultRemovedItems);
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
useEffect(() => {
|
||||
function onTopicClick(e: any) {
|
||||
const groupEl = e.target.closest('.clickable-group');
|
||||
@@ -69,7 +65,9 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
};
|
||||
}, [removedItems]);
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
let resourceJsonUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
@@ -151,11 +149,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
<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 h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
id={
|
||||
currentTeam?.type === 'company'
|
||||
? 'customized-roadmap'
|
||||
: 'original-roadmap'
|
||||
}
|
||||
id={'customized-roadmap'}
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
|
||||
147
src/components/CreateVersion/CreateVersion.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { GitFork, Layers2, 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;
|
||||
}
|
||||
|
||||
const roadmapEditorUrl = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${response?.roadmapId}`;
|
||||
|
||||
window.open(roadmapEditorUrl, '_blank');
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-[30px] w-[206px] 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
|
||||
</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?
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
createVersion().finally(() => null);
|
||||
}}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<span className="text-xs"> / </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
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../../lib/jwt';
|
||||
import { showLoginPopup } from '../../../lib/popup';
|
||||
import { cn } from '../../../lib/classname';
|
||||
import { CreateRoadmapModal } from './CreateRoadmapModal';
|
||||
import { useState } from 'react';
|
||||
|
||||
type CreateRoadmapButtonProps = {
|
||||
className?: string;
|
||||
text?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
|
||||
const { teamId, className, text = 'Create your own Roadmap' } = props;
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
function toggleCreateRoadmapHandler() {
|
||||
if (!isLoggedIn()) {
|
||||
return showLoginPopup();
|
||||
}
|
||||
|
||||
setIsCreatingRoadmap(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={teamId}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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,
|
||||
)}
|
||||
onClick={toggleCreateRoadmapHandler}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{text}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
type FormEvent,
|
||||
type MouseEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal } from '../../Modal';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
import { httpPost } from '../../../lib/http';
|
||||
import { cn } from '../../../lib/classname';
|
||||
|
||||
export const allowedRoadmapVisibility = [
|
||||
'me',
|
||||
'friends',
|
||||
'team',
|
||||
'public',
|
||||
] as const;
|
||||
export type AllowedRoadmapVisibility =
|
||||
(typeof allowedRoadmapVisibility)[number];
|
||||
export const allowedCustomRoadmapType = ['role', 'skill'] as const;
|
||||
export type AllowedCustomRoadmapType =
|
||||
(typeof allowedCustomRoadmapType)[number];
|
||||
|
||||
export interface RoadmapDocument {
|
||||
_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
creatorId: string;
|
||||
teamId?: string;
|
||||
isDiscoverable: boolean;
|
||||
type: AllowedCustomRoadmapType;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedFriendIds?: string[];
|
||||
sharedTeamMemberIds?: string[];
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
canManage: boolean;
|
||||
isCustomResource: boolean;
|
||||
}
|
||||
|
||||
interface CreateRoadmapModalProps {
|
||||
onClose: () => void;
|
||||
onCreated?: (roadmap: RoadmapDocument) => void;
|
||||
teamId?: string;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
}
|
||||
|
||||
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
const { onClose, onCreated, teamId } = props;
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const isInvalidDescription = description?.trim().length > 80;
|
||||
|
||||
async function handleSubmit(
|
||||
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
|
||||
redirect: boolean = true,
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.trim() === '' || isInvalidDescription) {
|
||||
toast.error('Please fill all the fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPost<RoadmapDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-create-roadmap`,
|
||||
{
|
||||
title,
|
||||
description,
|
||||
...(teamId && {
|
||||
teamId,
|
||||
}),
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap created successfully');
|
||||
if (redirect) {
|
||||
window.location.href = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${response?._id}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (onCreated) {
|
||||
onCreated(response as RoadmapDocument);
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
bodyClassName="p-4"
|
||||
wrapperClassName={cn(teamId && 'max-w-lg')}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-medium text-gray-900">Create Roadmap</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Add a title and description to your roadmap.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="block text-xs uppercase text-gray-400"
|
||||
>
|
||||
Roadmap Title
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
name="title"
|
||||
id="title"
|
||||
required
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="description"
|
||||
className="block text-xs uppercase text-gray-400"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
required
|
||||
className={cn(
|
||||
'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"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
|
||||
{description.length}/80
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<div className={cn('flex items-center gap-2', !teamId && 'w-full')}>
|
||||
{teamId && !isLoading && (
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={(e) => handleSubmit(e, false)}
|
||||
className="flex h-9 items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:bg-black hover:text-white focus:bg-black focus:text-white"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
'Save as Placeholder'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : teamId ? (
|
||||
'Continue to Editor'
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{teamId && (
|
||||
<>
|
||||
<p className="mt-4 hidden rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:block">
|
||||
Preparing the roadmap might take some time, feel free to save it
|
||||
as a placeholder and anyone with the role <strong>admin</strong>{' '}
|
||||
or <strong>manager</strong> can prepare it later.
|
||||
</p>
|
||||
<p className="mt-4 rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:hidden">
|
||||
Create a placeholder now and prepare it later.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
127
src/components/CustomRoadmap/CustomRoadmap.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getUrlParams } from '../../lib/browser';
|
||||
import {
|
||||
type AppError,
|
||||
type FetchError,
|
||||
httpGet,
|
||||
httpPost,
|
||||
} from '../../lib/http';
|
||||
import { RoadmapHeader } from './RoadmapHeader';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { currentRoadmap } from '../../stores/roadmap';
|
||||
import { RestrictedPage } from './RestrictedPage';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { FlowRoadmapRenderer } from './FlowRoadmapRenderer';
|
||||
|
||||
export const allowedLinkTypes = [
|
||||
'video',
|
||||
'article',
|
||||
'opensource',
|
||||
'course',
|
||||
'website',
|
||||
'podcast',
|
||||
] as const;
|
||||
|
||||
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
|
||||
|
||||
export interface RoadmapContentDocument {
|
||||
_id?: string;
|
||||
roadmapId: string;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
links: {
|
||||
id: string;
|
||||
type: AllowedLinkTypes;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
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]'
|
||||
) as HTMLElement;
|
||||
if (loaderEl) {
|
||||
loaderEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomRoadmap() {
|
||||
const { id, secret } = getUrlParams() as { id: string; secret: string };
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
|
||||
const [error, setError] = useState<AppError | FetchError | undefined>();
|
||||
|
||||
async function getRoadmap() {
|
||||
setIsLoading(true);
|
||||
|
||||
const roadmapUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
|
||||
);
|
||||
if (secret) {
|
||||
roadmapUrl.searchParams.set('secret', secret);
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<GetRoadmapResponse>(
|
||||
roadmapUrl.toString()
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `${response.title} - roadmap.sh`;
|
||||
|
||||
setRoadmap(response);
|
||||
currentRoadmap.set(response);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function trackVisit() {
|
||||
if (!isLoggedIn()) return;
|
||||
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
resourceId: id,
|
||||
resourceType: 'roadmap',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getRoadmap().finally(() => {
|
||||
hideRoadmapLoader();
|
||||
});
|
||||
trackVisit().then();
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <RestrictedPage error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoadmapHeader />
|
||||
<FlowRoadmapRenderer roadmap={roadmap!} />
|
||||
<TopicDetail canSubmitContribution={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
src/components/CustomRoadmap/EmptyRoadmap.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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}`;
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full items-center justify-center', className)}>
|
||||
<div className="flex flex-col items-center">
|
||||
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
|
||||
<h3 className="mt-2">This roadmap is currently empty.</h3>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
158
src/components/CustomRoadmap/FlowRoadmapRenderer.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ReadonlyEditor } from '../../../editor/readonly-editor';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
updateResourceProgress,
|
||||
type ResourceProgressType,
|
||||
renderTopicProgress,
|
||||
refreshProgressCounters,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { Node } from 'reactflow';
|
||||
import { useCallback, type MouseEvent, useMemo, useState, useRef } from 'react';
|
||||
import { EmptyRoadmap } from './EmptyRoadmap';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type FlowRoadmapRendererProps = {
|
||||
roadmap: RoadmapDocument;
|
||||
};
|
||||
|
||||
export function FlowRoadmapRenderer(props: FlowRoadmapRendererProps) {
|
||||
const { roadmap } = props;
|
||||
const roadmapId = String(roadmap._id!);
|
||||
|
||||
const [hideRenderer, setHideRenderer] = useState(false);
|
||||
const editorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function updateTopicStatus(
|
||||
topicId: string,
|
||||
newStatus: ResourceProgressType,
|
||||
) {
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
topicId,
|
||||
},
|
||||
newStatus,
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
refreshProgressCounters();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTopicRightClick = useCallback((e: MouseEvent, node: Node) => {
|
||||
const target = e?.currentTarget as HTMLDivElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusDone = target?.classList.contains('done');
|
||||
updateTopicStatus(node.id, isCurrentStatusDone ? 'pending' : 'done');
|
||||
}, []);
|
||||
|
||||
const handleTopicShiftClick = useCallback((e: MouseEvent, node: Node) => {
|
||||
const target = e?.currentTarget as HTMLDivElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusLearning = target?.classList.contains('learning');
|
||||
updateTopicStatus(
|
||||
node.id,
|
||||
isCurrentStatusLearning ? 'pending' : 'learning',
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleTopicAltClick = useCallback((e: MouseEvent, node: Node) => {
|
||||
const target = e?.currentTarget as HTMLDivElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusSkipped = target?.classList.contains('skipped');
|
||||
updateTopicStatus(node.id, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||||
}, []);
|
||||
|
||||
const handleTopicClick = useCallback((e: MouseEvent, node: Node) => {
|
||||
const target = e?.currentTarget as HTMLDivElement;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('roadmap.node.click', {
|
||||
detail: {
|
||||
topicId: node.id,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
isCustomResource: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleLinkClick = useCallback((linkId: string, href: string) => {
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isExternalLink = href.startsWith('http');
|
||||
if (isExternalLink) {
|
||||
window.open(href, '_blank');
|
||||
} else {
|
||||
window.location.href = href;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hideRenderer && (
|
||||
<EmptyRoadmap
|
||||
roadmapId={roadmapId}
|
||||
canManage={roadmap.canManage}
|
||||
className="grow"
|
||||
/>
|
||||
)}
|
||||
<ReadonlyEditor
|
||||
ref={editorWrapperRef}
|
||||
roadmap={roadmap}
|
||||
className={cn(
|
||||
roadmap?.nodes?.length === 0
|
||||
? 'grow'
|
||||
: 'min-h-0 max-md:min-h-[1000px]',
|
||||
)}
|
||||
onRendered={() => {
|
||||
renderResourceProgress('roadmap', roadmapId).then(() => {
|
||||
if (roadmap?.nodes?.length === 0) {
|
||||
setHideRenderer(true);
|
||||
editorWrapperRef?.current?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}}
|
||||
onTopicClick={handleTopicClick}
|
||||
onTopicRightClick={handleTopicRightClick}
|
||||
onTopicShiftClick={handleTopicShiftClick}
|
||||
onTopicAltClick={handleTopicAltClick}
|
||||
onButtonNodeClick={handleLinkClick}
|
||||
onLinkClick={handleLinkClick}
|
||||
fontFamily="Balsamiq Sans"
|
||||
fontURL="/fonts/balsamiq.woff2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import MoreIcon from '../../icons/more-vertical.svg';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type PersonalRoadmapActionDropdownProps = {
|
||||
onDelete?: () => void;
|
||||
onCustomize?: () => void;
|
||||
onUpdateSharing?: () => void;
|
||||
};
|
||||
|
||||
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) {
|
||||
const { onDelete, onUpdateSharing, onCustomize } = props;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
|
||||
>
|
||||
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
Options
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
|
||||
>
|
||||
<ul>
|
||||
{onUpdateSharing && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onUpdateSharing();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Lock size={14} className="mr-2" />
|
||||
Sharing
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
src/components/CustomRoadmap/PersonalRoadmapList.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import {
|
||||
ExternalLink,
|
||||
Shapes,
|
||||
type LucideIcon,
|
||||
Globe,
|
||||
LockIcon,
|
||||
Users,
|
||||
PenSquare,
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import {
|
||||
type AllowedRoadmapVisibility,
|
||||
type RoadmapDocument,
|
||||
} from './CreateRoadmap/CreateRoadmapModal';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
|
||||
import type { GetRoadmapListResponse } from './RoadmapListPage';
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
|
||||
type PersonalRoadmapListType = {
|
||||
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
|
||||
onDelete: (roadmapId: string) => void;
|
||||
setAllRoadmaps: Dispatch<SetStateAction<GetRoadmapListResponse>>;
|
||||
};
|
||||
|
||||
export function PersonalRoadmapList(props: PersonalRoadmapListType) {
|
||||
const { roadmaps: roadmapList, onDelete, setAllRoadmaps } = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [selectedRoadmap, setSelectedRoadmap] = useState<
|
||||
GetRoadmapListResponse['personalRoadmaps'][number] | null
|
||||
>(null);
|
||||
|
||||
async function deleteRoadmap(roadmapId: string) {
|
||||
const { response, error } = await httpDelete<RoadmapDocument[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap deleted');
|
||||
onDelete(roadmapId);
|
||||
}
|
||||
|
||||
async function onRemove(roadmapId: string) {
|
||||
pageProgressMessage.set('Deleting roadmap');
|
||||
|
||||
deleteRoadmap(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
const shareSettingsModal = selectedRoadmap && (
|
||||
<ShareOptionsModal
|
||||
isDiscoverable={selectedRoadmap.isDiscoverable}
|
||||
description={selectedRoadmap.description}
|
||||
visibility={selectedRoadmap.visibility}
|
||||
sharedFriendIds={selectedRoadmap.sharedFriendIds}
|
||||
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds}
|
||||
roadmapId={selectedRoadmap._id!}
|
||||
onClose={() => setSelectedRoadmap(null)}
|
||||
onShareSettingsUpdate={(settings) => {
|
||||
setAllRoadmaps((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
personalRoadmaps: prev.personalRoadmaps.map((roadmap) => {
|
||||
if (roadmap._id === selectedRoadmap._id) {
|
||||
return {
|
||||
...roadmap,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
|
||||
return roadmap;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (roadmapList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
<img
|
||||
alt="roadmap"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-4 h-24 w-24 opacity-10"
|
||||
/>
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
Create a roadmap to get started
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{shareSettingsModal}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-sm text-gray-400'}>
|
||||
{roadmapList.length} custom roadmap(s)
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col divide-y rounded-md border">
|
||||
{roadmapList.map((roadmap) => {
|
||||
return (
|
||||
<CustomRoadmapItem
|
||||
key={roadmap._id!}
|
||||
roadmap={roadmap}
|
||||
onRemove={onRemove}
|
||||
setSelectedRoadmap={setSelectedRoadmap}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CustomRoadmapItemProps = {
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number];
|
||||
onRemove: (roadmapId: string) => Promise<void>;
|
||||
setSelectedRoadmap: (
|
||||
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null
|
||||
) => void;
|
||||
};
|
||||
|
||||
function CustomRoadmapItem(props: CustomRoadmapItemProps) {
|
||||
const { roadmap, onRemove, setSelectedRoadmap } = props;
|
||||
|
||||
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmap._id}`;
|
||||
|
||||
return (
|
||||
<li
|
||||
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">
|
||||
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
|
||||
{roadmap.title}
|
||||
</p>
|
||||
<span className="flex items-center text-xs leading-none text-gray-400">
|
||||
<VisibilityBadge
|
||||
visibility={roadmap.visibility!}
|
||||
sharedFriendIds={roadmap.sharedFriendIds}
|
||||
/>
|
||||
<span className="mx-2 font-semibold">·</span>
|
||||
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
|
||||
{roadmap.topics} topic
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-1 flex items-center justify-start sm:justify-end">
|
||||
<PersonalRoadmapActionDropdown
|
||||
onUpdateSharing={() => {
|
||||
setSelectedRoadmap(roadmap);
|
||||
}}
|
||||
onCustomize={() => {
|
||||
window.open(editorLink, '_blank');
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (confirm('Are you sure you want to remove this roadmap?')) {
|
||||
onRemove(roadmap._id!).finally(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<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-blue-400 bg-white px-2 py-1.5 text-xs hover:bg-blue-50 focus:outline-none text-blue-600'
|
||||
}
|
||||
target={'_blank'}
|
||||
>
|
||||
<ExternalLink className="inline-block h-4 w-4" />
|
||||
Visit
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type VisibilityLabelProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedFriendIds?: string[];
|
||||
};
|
||||
|
||||
const visibilityDetails: Record<
|
||||
AllowedRoadmapVisibility,
|
||||
{
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
> = {
|
||||
public: {
|
||||
icon: Globe,
|
||||
label: 'Public',
|
||||
},
|
||||
me: {
|
||||
icon: LockIcon,
|
||||
label: 'Only me',
|
||||
},
|
||||
team: {
|
||||
icon: Users,
|
||||
label: 'Team Member(s)',
|
||||
},
|
||||
friends: {
|
||||
icon: Users,
|
||||
label: 'Friend(s)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
function VisibilityBadge(props: VisibilityLabelProps) {
|
||||
const { visibility, sharedFriendIds = [] } = props;
|
||||
|
||||
const { label, icon: Icon } = visibilityDetails[visibility];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
|
||||
>
|
||||
<Icon className="inline-block h-3 w-3" />
|
||||
<div className="flex items-center">
|
||||
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
|
||||
<span className="mr-1">{sharedFriendIds.length}</span>
|
||||
)}
|
||||
{label}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
113
src/components/CustomRoadmap/ResourceProgressStats.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
|
||||
type ResourceProgressStatsProps = {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
isSecondaryBanner?: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgressStats(props: ResourceProgressStatsProps) {
|
||||
const { isSecondaryBanner = false } = props;
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSharing && $canManageCurrentRoadmap && $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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-progress-nums-container=""
|
||||
className={cn(
|
||||
'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,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className="flex text-sm opacity-0 transition-opacity duration-300"
|
||||
data-progress-nums=""
|
||||
>
|
||||
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
|
||||
<span data-progress-percentage="">0</span>% Done
|
||||
</span>
|
||||
|
||||
<span className="itesm-center hidden md:flex">
|
||||
<span>
|
||||
<span data-progress-done="">0</span> completed
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-learning="">0</span> in progress
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-skipped="">0</span> skipped
|
||||
</span>
|
||||
<span className="mx-1.5 text-gray-400">·</span>
|
||||
<span>
|
||||
<span data-progress-total="">0</span> Total
|
||||
</span>
|
||||
</span>
|
||||
<span className="md:hidden">
|
||||
<span data-progress-done="">0</span> of{' '}
|
||||
<span data-progress-total="">0</span> Done
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-3 opacity-0 transition-opacity duration-300"
|
||||
data-progress-nums=""
|
||||
>
|
||||
<button
|
||||
data-popup="progress-help"
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black"
|
||||
data-progress-nums=""
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5 stroke-[2.5px]" />
|
||||
Track Progress
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-progress-nums-container=""
|
||||
className="striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
|
||||
>
|
||||
<span
|
||||
data-progress-nums=""
|
||||
className="text-gray-500 opacity-0 transition-opacity duration-300"
|
||||
>
|
||||
<span data-progress-done="">0</span> of{' '}
|
||||
<span data-progress-total="">0</span> Done
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
src/components/CustomRoadmap/RestrictedPage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ShieldBan } from 'lucide-react';
|
||||
import type { FetchError } from '../../lib/http';
|
||||
|
||||
type RestrictedPageProps = {
|
||||
error: FetchError;
|
||||
};
|
||||
|
||||
export function RestrictedPage(props: RestrictedPageProps) {
|
||||
const { error } = props;
|
||||
|
||||
if (error.status === 404) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
icon={<ShieldBan className="h-16 w-16" />}
|
||||
title="Roadmap not found"
|
||||
message="The roadmap you are looking for does not exist or has been deleted."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorMessage
|
||||
icon={<ShieldBan className="h-16 w-16" />}
|
||||
title="Restricted Access"
|
||||
message={error?.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ErrorMessageProps = {
|
||||
title: string;
|
||||
message: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
function ErrorMessage(props: ErrorMessageProps) {
|
||||
const { title, message, icon } = props;
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center">
|
||||
{icon}
|
||||
<h2 className="mt-4 text-2xl font-semibold">{title}</h2>
|
||||
<p>{message || 'This roadmap is not available for public access.'}</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-4 font-medium underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
← Go back to home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/CustomRoadmap/RoadmapActionButton.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
|
||||
|
||||
type RoadmapActionButtonProps = {
|
||||
onDelete?: () => void;
|
||||
onCustomize?: () => void;
|
||||
onUpdateSharing?: () => void;
|
||||
};
|
||||
|
||||
export function RoadmapActionButton(props: RoadmapActionButtonProps) {
|
||||
const { onDelete, onUpdateSharing, onCustomize } = props;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useOutsideClick(menuRef, () => {
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
disabled={false}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
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>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
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 && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onUpdateSharing();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Lock size={14} className="mr-2" />
|
||||
Sharing
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onCustomize && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onCustomize();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Shapes size={14} className="mr-2" />
|
||||
Customize
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{onDelete && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/components/CustomRoadmap/RoadmapHeader.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { RoadmapHint } from './RoadmapHint';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
|
||||
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
|
||||
import { useState } from 'react';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
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 = {};
|
||||
|
||||
export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
|
||||
const $currentRoadmap = 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() {
|
||||
pageProgressMessage.set('Deleting roadmap');
|
||||
|
||||
const teamId = $currentRoadmap?.teamId;
|
||||
const baseApiUrl = import.meta.env.PUBLIC_API_URL;
|
||||
|
||||
let error, response;
|
||||
if (teamId) {
|
||||
({ error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${baseApiUrl}/v1-delete-team-resource-config/${teamId}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
));
|
||||
} else {
|
||||
({ error, response } = await httpDelete<TeamResourceConfig>(
|
||||
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`
|
||||
));
|
||||
}
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Roadmap removed');
|
||||
if (!teamId) {
|
||||
window.location.href = '/account/roadmaps';
|
||||
} else {
|
||||
window.location.href = `/team/roadmaps?t=${teamId}`;
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
{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
|
||||
<span className="font-semibold text-gray-900">
|
||||
{creator?.name}
|
||||
</span>
|
||||
{team && (
|
||||
<>
|
||||
from
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="flex gap-1 sm:gap-2">
|
||||
<a
|
||||
href="/roadmaps"
|
||||
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Roadmaps"
|
||||
>
|
||||
←<span className="hidden sm:inline"> All Roadmaps</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup="login-popup"
|
||||
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Subscribe for Updates"
|
||||
>
|
||||
<span className="ml-2">Subscribe</span>
|
||||
</button>
|
||||
</div>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!$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
|
||||
roadmapTitle={title!}
|
||||
hasTNSBanner={false}
|
||||
roadmapId={roadmapId!}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/CustomRoadmap/RoadmapHint.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
import { ResourceProgressStats } from './ResourceProgressStats';
|
||||
|
||||
type RoadmapHintProps = {
|
||||
roadmapId: string;
|
||||
roadmapTitle: string;
|
||||
hasTNSBanner?: boolean;
|
||||
tnsBannerLink?: string;
|
||||
};
|
||||
|
||||
export function RoadmapHint(props: RoadmapHintProps) {
|
||||
const {
|
||||
roadmapTitle,
|
||||
roadmapId,
|
||||
hasTNSBanner = false,
|
||||
tnsBannerLink = '',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('mb-0 mt-4 rounded-md border-0 sm:mt-7 sm:border', {
|
||||
'sm:-mb-[82px]': hasTNSBanner,
|
||||
'sm:-mb-[65px]': !hasTNSBanner,
|
||||
})}
|
||||
>
|
||||
{hasTNSBanner && (
|
||||
<div className="hidden border-b bg-gray-100 px-2 py-1.5 sm:block">
|
||||
<p className="text-sm">
|
||||
Get the latest {roadmapTitle} news from our sister site{' '}
|
||||
<a
|
||||
href={tnsBannerLink}
|
||||
target="_blank"
|
||||
className="font-semibold underline"
|
||||
>
|
||||
TheNewStack.io
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResourceProgressStats
|
||||
isSecondaryBanner={hasTNSBanner}
|
||||
resourceId={roadmapId}
|
||||
resourceType="roadmap"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/CustomRoadmap/RoadmapListPage.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import {
|
||||
CreateRoadmapModal,
|
||||
type RoadmapDocument,
|
||||
} from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { PersonalRoadmapList } from './PersonalRoadmapList';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { SharedRoadmapList } from './SharedRoadmapList';
|
||||
import type { FriendshipStatus } from '../Befriend';
|
||||
|
||||
export type FriendUserType = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
status: FriendshipStatus;
|
||||
};
|
||||
|
||||
export type GetRoadmapListResponse = {
|
||||
personalRoadmaps: (RoadmapDocument & {
|
||||
topics: number;
|
||||
})[];
|
||||
sharedRoadmaps: (RoadmapDocument & {
|
||||
topics: number;
|
||||
creator: FriendUserType;
|
||||
})[];
|
||||
};
|
||||
|
||||
type TabType = {
|
||||
label: string;
|
||||
value: 'personal' | 'shared';
|
||||
};
|
||||
|
||||
const tabTypes: TabType[] = [
|
||||
{ label: 'Personal', value: 'personal' },
|
||||
{ label: 'Shared by Friends', value: 'shared' },
|
||||
];
|
||||
|
||||
export function RoadmapListPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType['value']>('personal');
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<GetRoadmapListResponse>({
|
||||
personalRoadmaps: [],
|
||||
sharedRoadmaps: [],
|
||||
});
|
||||
|
||||
async function loadRoadmapList() {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<GetRoadmapListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
setAllRoadmaps(
|
||||
response! || {
|
||||
personalRoadmaps: [],
|
||||
sharedRoadmaps: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRoadmapList().finally(() => {
|
||||
setIsLoading(false);
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
|
||||
<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 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)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`}
|
||||
onClick={() => setIsCreatingRoadmap(true)}
|
||||
>
|
||||
+ Create Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{activeTab === 'personal' && (
|
||||
<PersonalRoadmapList
|
||||
roadmaps={allRoadmaps?.personalRoadmaps}
|
||||
setAllRoadmaps={setAllRoadmaps}
|
||||
onDelete={(roadmapId) => {
|
||||
setAllRoadmaps({
|
||||
...allRoadmaps,
|
||||
personalRoadmaps: allRoadmaps.personalRoadmaps.filter(
|
||||
(r) => r._id !== roadmapId
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'shared' && (
|
||||
<SharedRoadmapList roadmaps={allRoadmaps?.sharedRoadmaps} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/components/CustomRoadmap/ShareRoadmapModal.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { Check, Copy, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Modal } from '../Modal';
|
||||
import type { AllowedRoadmapVisibility } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap';
|
||||
|
||||
type ShareRoadmapModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const allowedVisibilityLabels: {
|
||||
id: AllowedRoadmapVisibility;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 'me',
|
||||
label: 'Only visible to me',
|
||||
},
|
||||
{
|
||||
id: 'public',
|
||||
label: 'Anyone with the link',
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Visible to team members',
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
label: 'Only friends can view',
|
||||
},
|
||||
];
|
||||
|
||||
export function ShareRoadmapModal(props: ShareRoadmapModalProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal);
|
||||
const roadmapId = $currentRoadmap?._id!;
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
const [visibility, setVisibility] = useState($currentRoadmap?.visibility);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function updateVisibility(newVisibility: AllowedRoadmapVisibility) {
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-roadmap-visibility/${
|
||||
$currentRoadmap?._id
|
||||
}`,
|
||||
{
|
||||
visibility: newVisibility,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
toast.success('Visibility updated');
|
||||
setVisibility(newVisibility);
|
||||
currentRoadmap.set({
|
||||
...$currentRoadmap!,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
const isDev = import.meta.env.DEV;
|
||||
const url = new URL(
|
||||
isDev ? 'http://localhost:3000/r' : 'https://roadmap.sh/r'
|
||||
);
|
||||
url.searchParams.set('id', roadmapId);
|
||||
copyText(url.toString());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose}>
|
||||
<div className="p-4 pb-0">
|
||||
<h1 className="text-lg font-medium leading-5 text-gray-900">
|
||||
Updating {$currentRoadmap?.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 border-t">
|
||||
{allowedVisibilityLabels.map((v) => {
|
||||
if (v.id === 'team' && $isCurrentRoadmapPersonal) {
|
||||
return null;
|
||||
} else if (v.id === 'friends' && !$isCurrentRoadmapPersonal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<button
|
||||
disabled={v.id === visibility || isLoading}
|
||||
key={v.id}
|
||||
className={cn(
|
||||
'relative flex w-full items-center border-b p-2.5 px-4 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed',
|
||||
v.id === visibility &&
|
||||
'bg-gray-900 text-white hover:bg-gray-900 hover:text-white'
|
||||
)}
|
||||
onClick={() => updateVisibility(v.id)}
|
||||
>
|
||||
{v.label}
|
||||
|
||||
{v.id === visibility && (
|
||||
<span className="absolute bottom-0 right-0 top-0 flex w-8 items-center justify-center">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<button
|
||||
disabled={isLoading}
|
||||
className="flex h-9 items-center 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 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
onClick={onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 size={14} className="mr-2 animate-spin stroke-[2.5]" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
'Cancel'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-9 items-center justify-center rounded-md border border-transparent bg-gray-900 px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check size={14} className="mr-2 stroke-[2.5]" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} className="mr-2 stroke-[2.5]" />
|
||||
Copy Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
118
src/components/CustomRoadmap/SharedRoadmapList.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ExternalLinkIcon, Map, Plus } from 'lucide-react';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import type { GetRoadmapListResponse } from './RoadmapListPage';
|
||||
|
||||
type GroupByCreator = {
|
||||
creator: GetRoadmapListResponse['sharedRoadmaps'][number]['creator'];
|
||||
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
|
||||
};
|
||||
|
||||
type SharedRoadmapListProps = {
|
||||
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
|
||||
};
|
||||
|
||||
export function SharedRoadmapList(props: SharedRoadmapListProps) {
|
||||
const { roadmaps: sharedRoadmaps } = props;
|
||||
|
||||
const allUniqueCreatorIds = new Set(
|
||||
sharedRoadmaps.map((roadmap) => roadmap.creator.id)
|
||||
);
|
||||
|
||||
const groupByCreator: GroupByCreator[] = [];
|
||||
for (const creatorId of allUniqueCreatorIds) {
|
||||
const creator = sharedRoadmaps.find(
|
||||
(roadmap) => roadmap.creator.id === creatorId
|
||||
)?.creator;
|
||||
if (!creator) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupByCreator.push({
|
||||
creator,
|
||||
roadmaps: sharedRoadmaps.filter(
|
||||
(roadmap) => roadmap.creator.id === creatorId
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (sharedRoadmaps.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center p-4 py-20">
|
||||
<Map className="mb-4 h-24 w-24 opacity-10" />
|
||||
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
|
||||
<p className="text-base text-gray-500">
|
||||
Roadmaps from your friends will appear here
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className={'text-sm text-gray-400'}>
|
||||
{sharedRoadmaps.length} shared roadmap(s)
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{groupByCreator.map((group) => {
|
||||
const creator = group.creator;
|
||||
return (
|
||||
<li
|
||||
key={creator.id}
|
||||
className="flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
|
||||
>
|
||||
<div className="relative flex w-full items-center gap-3 p-3">
|
||||
<img
|
||||
src={
|
||||
creator.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
creator.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={creator.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="truncate font-medium">{creator.name}</h3>
|
||||
<p className="truncate text-sm text-gray-500">
|
||||
{group?.roadmaps?.length || 0} shared roadmap(s)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="w-full">
|
||||
{group?.roadmaps?.map((roadmap) => {
|
||||
return (
|
||||
<li
|
||||
key={roadmap._id}
|
||||
className="relative flex w-full border-t"
|
||||
>
|
||||
<a
|
||||
href={`/r?id=${roadmap._id}`}
|
||||
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black"
|
||||
target={'_blank'}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{roadmap.title}
|
||||
</span>
|
||||
|
||||
<ExternalLinkIcon
|
||||
size={16}
|
||||
className="opacity-20 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/CustomRoadmap/SkeletonRoadmapHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
export function SkeletonRoadmapHeader() {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
<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] 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">
|
||||
<div
|
||||
data-progress-nums-container
|
||||
className="striped-loader relative hidden h-8 items-center justify-between rounded-md bg-white sm:flex"
|
||||
/>
|
||||
<div
|
||||
data-progress-nums-container
|
||||
className="striped-loader relative -mb-2 flex h-[34px] items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
---
|
||||
import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import FeaturedItem, { type FeaturedItemType } from './FeaturedItem.astro';
|
||||
|
||||
export interface Props {
|
||||
featuredItems: FeaturedItemType[];
|
||||
heading: string;
|
||||
showCreateRoadmap?: boolean;
|
||||
allowBookmark?: boolean;
|
||||
}
|
||||
|
||||
const { featuredItems, heading, allowBookmark = true } = Astro.props;
|
||||
const {
|
||||
featuredItems,
|
||||
heading,
|
||||
showCreateRoadmap,
|
||||
allowBookmark = true,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
|
||||
@@ -32,6 +39,13 @@ const { featuredItems, heading, allowBookmark = true } = Astro.props;
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
showCreateRoadmap && (
|
||||
<li>
|
||||
<CreateRoadmapButton client:load className='min-h-[54px]' />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'>
|
||||
@@ -67,20 +68,30 @@ import Icon from './AstroIcon.astro';
|
||||
<a href='/privacy' class='hover:text-white'>Privacy</a>
|
||||
<span class='mx-1.5'>·</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>
|
||||
|
||||
@@ -10,7 +10,10 @@ import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
||||
|
||||
type FriendProgressItemProps = {
|
||||
friend: ListFriendsResponse[0];
|
||||
onShowResourceProgress: (resourceId: string) => void;
|
||||
onShowResourceProgress: (
|
||||
resourceId: string,
|
||||
isCustomResource?: boolean
|
||||
) => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
|
||||
@@ -52,7 +55,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
onReload();
|
||||
}
|
||||
|
||||
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
|
||||
const roadmaps = (friend?.roadmaps || []).sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
});
|
||||
|
||||
@@ -86,7 +89,12 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onShowResourceProgress(progress.resourceId)}
|
||||
onClick={() =>
|
||||
onShowResourceProgress(
|
||||
progress.resourceId,
|
||||
progress.isCustomResource
|
||||
)
|
||||
}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
key={progress.resourceId}
|
||||
>
|
||||
|
||||
@@ -10,12 +10,14 @@ 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;
|
||||
title: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
isCustomResource: boolean;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
@@ -52,6 +54,7 @@ export function FriendsPage() {
|
||||
const [showFriendProgress, setShowFriendProgress] = useState<{
|
||||
resourceId: string;
|
||||
friend: ListFriendsResponse[0];
|
||||
isCustomResource?: boolean;
|
||||
}>();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -105,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 && (
|
||||
@@ -114,14 +136,7 @@ export function FriendsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFriendProgress && (
|
||||
<UserProgressModal
|
||||
userId={showFriendProgress.friend.userId}
|
||||
resourceId={showFriendProgress.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => setShowFriendProgress(undefined)}
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
@@ -167,10 +182,11 @@ export function FriendsPage() {
|
||||
{filteredFriends.map((friend) => (
|
||||
<FriendProgressItem
|
||||
friend={friend}
|
||||
onShowResourceProgress={(resourceId) => {
|
||||
onShowResourceProgress={(resourceId, isCustomResource) => {
|
||||
setShowFriendProgress({
|
||||
resourceId,
|
||||
friend,
|
||||
isCustomResource,
|
||||
});
|
||||
}}
|
||||
key={friend.userId}
|
||||
|
||||
@@ -29,12 +29,7 @@ export function SidebarFriendsCounter() {
|
||||
|
||||
const pendingCount = friendCounts?.receivedCount || 0;
|
||||
if (!pendingCount) {
|
||||
return (
|
||||
<span className="relative mr-1 flex items-center">
|
||||
<span className="relative rounded-full bg-gray-200 p-1 text-xs" />
|
||||
<span className="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
|
||||
</span>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { HeroRoadmaps } from './HeroRoadmaps';
|
||||
import {isLoggedIn} from "../../lib/jwt";
|
||||
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
@@ -14,6 +15,12 @@ export type UserProgressResponse = {
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
team?: {
|
||||
name: string;
|
||||
id: string;
|
||||
role: AllowedMemberRoles;
|
||||
};
|
||||
}[];
|
||||
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
@@ -48,6 +55,8 @@ function renderProgress(progressList: UserProgressResponse) {
|
||||
});
|
||||
}
|
||||
|
||||
type ProgressResponse = UserProgressResponse;
|
||||
|
||||
export function FavoriteRoadmaps() {
|
||||
const isAuthenticated = isLoggedIn();
|
||||
if (!isAuthenticated) {
|
||||
@@ -56,7 +65,7 @@ export function FavoriteRoadmaps() {
|
||||
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<UserProgressResponse>([]);
|
||||
const [progress, setProgress] = useState<ProgressResponse>([]);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
|
||||
function showProgressContainer() {
|
||||
@@ -79,10 +88,9 @@ export function FavoriteRoadmaps() {
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } =
|
||||
await httpGet<UserProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
||||
);
|
||||
const { response: progressList, error } = await httpGet<ProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
return;
|
||||
@@ -111,19 +119,44 @@ export function FavoriteRoadmaps() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProgress = progress.length > 0;
|
||||
const hasProgress = progress?.length > 0;
|
||||
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 />}
|
||||
{progress.length > 0 && (
|
||||
<HeroRoadmaps customRoadmaps={[]} progress={progress} 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>
|
||||
);
|
||||
|
||||
@@ -3,7 +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 { type ReactNode, useState } from 'react';
|
||||
import { TeamAnnouncement } from '../TeamAnnouncement';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
@@ -52,7 +56,7 @@ function HeroRoadmap(props: ProgressRoadmapProps) {
|
||||
type ProgressTitleProps = {
|
||||
icon: any;
|
||||
isLoading?: boolean;
|
||||
title: string;
|
||||
title: string | ReactNode;
|
||||
};
|
||||
|
||||
export function HeroTitle(props: ProgressTitleProps) {
|
||||
@@ -70,24 +74,40 @@ export function HeroTitle(props: ProgressTitleProps) {
|
||||
</p>
|
||||
);
|
||||
}
|
||||
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>;
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
showCustomRoadmaps?: boolean;
|
||||
customRoadmaps: any[]; // @fixme implement this
|
||||
customRoadmaps: UserProgressResponse;
|
||||
teamRoadmaps?: HeroTeamRoadmaps;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function HeroRoadmaps(props: ProgressListProps) {
|
||||
const {
|
||||
teamRoadmaps = {},
|
||||
progress,
|
||||
isLoading = false,
|
||||
customRoadmaps = [{} /* @fixme implement this */],
|
||||
showCustomRoadmaps = 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
|
||||
teamId={creatingRoadmapTeamId}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
setCreatingRoadmapTeamId(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
<HeroTitle
|
||||
icon={
|
||||
@@ -118,38 +138,127 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showCustomRoadmaps && (
|
||||
<div className="mt-5">
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title="Your custom roadmaps"
|
||||
/>
|
||||
}
|
||||
<div className="mt-5">
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title="Your custom roadmaps"
|
||||
/>
|
||||
}
|
||||
|
||||
{customRoadmaps.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
You haven't created any custom roadmaps yet.{' '}
|
||||
<button className="text-gray-500 underline underline-offset-2 hover:text-gray-400">
|
||||
Create one!
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{customRoadmaps.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
You haven't created any custom roadmaps yet.{' '}
|
||||
<button
|
||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
|
||||
onClick={() => setIsCreatingRoadmap(true)}
|
||||
>
|
||||
Create one!
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{customRoadmaps.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{customRoadmaps.map((customRoadmap) => (
|
||||
<HeroRoadmap
|
||||
resourceId={'343434'}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={'Frontend Roadmap Revised'}
|
||||
percentageDone={50}
|
||||
url={`/r?${'34343434'}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
))}
|
||||
{customRoadmaps.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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<CreateRoadmapButton />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
46
src/components/Modal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { type ReactNode, useRef } from 'react';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { useKeydown } from '../hooks/use-keydown';
|
||||
import { cn } from '../lib/classname';
|
||||
|
||||
type ModalProps = {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
bodyClassName?: string;
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
const { onClose, children, bodyClassName, wrapperClassName } = props;
|
||||
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full w-full max-w-md p-4 md:h-auto',
|
||||
wrapperClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className={cn(
|
||||
'popup-body relative h-full rounded-lg bg-white shadow',
|
||||
bodyClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import Icon from '../AstroIcon.astro';
|
||||
|
||||
<div class='relative hidden' data-auth-required>
|
||||
<button
|
||||
class='flex h-8 w-28 items-center justify-center 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'
|
||||
class='flex h-8 w-38 items-center justify-center 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'
|
||||
type='button'
|
||||
data-account-button
|
||||
>
|
||||
<span class='inline-flex items-center gap-1.5'>
|
||||
Account
|
||||
Account <span class="text-gray-300">/</span> Teams
|
||||
<Icon
|
||||
icon='chevron-down'
|
||||
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
|
||||
|
||||
68
src/components/Navigation/AccountDropdown.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
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);
|
||||
setIsTeamsOpen(false);
|
||||
});
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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={() => {
|
||||
setIsTeamsOpen(false);
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center">
|
||||
Account <span className="text-gray-300">/</span> Teams
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
|
||||
>
|
||||
{isTeamsOpen ? (
|
||||
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
|
||||
) : (
|
||||
<AccountDropdownList
|
||||
onCreateRoadmap={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
setIsTeamsOpen={setIsTeamsOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/Navigation/AccountDropdownList.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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, onCreateRoadmap } = props;
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
return (
|
||||
<ul>
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account"
|
||||
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="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="mt-1 border-t border-t-gray-700/60 px-1 pt-1">
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
<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="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>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
110
src/components/Navigation/DropdownTeamList.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ChevronLeft, Loader2, Plus, Users } from 'lucide-react';
|
||||
import { $teamList } from '../../stores/team';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type DropdownTeamListProps = {
|
||||
setIsTeamsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export function DropdownTeamList(props: DropdownTeamListProps) {
|
||||
const { setIsTeamsOpen } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const teamList = useStore($teamList);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getAllTeams() {
|
||||
if (teamList.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<TeamListResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
$teamList.set(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeams().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadingIndicator = isLoading && (
|
||||
<div className="mt-2 flex animate-pulse flex-col gap-1 px-1 text-center">
|
||||
<div className="h-[35px] rounded-md bg-gray-700"></div>
|
||||
<div className="h-[35px] rounded-md bg-gray-700"></div>
|
||||
<div className="h-[35px] rounded-md bg-gray-700"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<button
|
||||
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
|
||||
onClick={() => setIsTeamsOpen(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 stroke-[2.5px]" />
|
||||
</button>
|
||||
<a
|
||||
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
|
||||
href="/team/new"
|
||||
>
|
||||
<Plus className="h-4 w-4 stroke-[2.5px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loadingIndicator}
|
||||
{!isLoading && (
|
||||
<ul className="mt-2">
|
||||
{teamList?.map((team) => {
|
||||
let pageLink = '';
|
||||
if (team.status === 'invited') {
|
||||
pageLink = `/respond-invite?i=${team.memberId}`;
|
||||
} else if (team.status === 'joined') {
|
||||
pageLink = `/team/progress?t=${team._id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={team._id} className="px-1">
|
||||
<a
|
||||
href={pageLink}
|
||||
className="block truncate rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
{team.name}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{teamList.length === 0 && !isLoading && (
|
||||
<li className="mt-2 px-1 text-center">
|
||||
<p className="block rounded px-4 py-2 text-sm font-medium text-slate-500">
|
||||
<Users className="mx-auto mb-2 h-7 w-7 text-slate-600" />
|
||||
No teams found.{' '}
|
||||
<a
|
||||
className="font-medium text-slate-400 underline underline-offset-2 hover:text-slate-300"
|
||||
href="/team/new"
|
||||
>
|
||||
Create a team
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import AccountDropdown from './AccountDropdown.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
|
||||
@@ -24,10 +23,28 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a>
|
||||
<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
|
||||
@@ -44,7 +61,7 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
</li>
|
||||
<li>
|
||||
<AccountDropdown />
|
||||
<AccountDropdown client:only='react' />
|
||||
|
||||
<a
|
||||
data-guest-required
|
||||
@@ -108,6 +125,11 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
<a href='/team' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
<button
|
||||
data-logout-button
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -23,7 +23,7 @@ function ProgressStatButton(props: ProgressStatButtonProps) {
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
className="group relative text-sm sm:text-base flex flex-1 items-center overflow-hidden rounded-md sm:rounded-xl border border-gray-300 bg-white py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50"
|
||||
className="group relative flex flex-1 items-center overflow-hidden rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50 sm:rounded-xl sm:px-4 sm:py-3 sm:text-base"
|
||||
>
|
||||
{icon}
|
||||
<span className="flex flex-grow justify-between">
|
||||
@@ -31,7 +31,7 @@ function ProgressStatButton(props: ProgressStatButtonProps) {
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
|
||||
<span className="absolute top-full left-0 right-0 flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
|
||||
<span className="absolute left-0 right-0 top-full flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
|
||||
Restart Asking
|
||||
</span>
|
||||
</button>
|
||||
@@ -62,7 +62,7 @@ export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
<span className="inline sm:hidden">questions</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-5 mb-5 flex w-full flex-col gap-1.5 sm:gap-3 px-2 sm:flex-row sm:px-16">
|
||||
<div className="mb-5 mt-5 flex w-full flex-col gap-1.5 px-2 sm:flex-row sm:gap-3 sm:px-16">
|
||||
<ProgressStatButton
|
||||
icon={<ThumbsUp className="mr-1 h-4" />}
|
||||
label="Knew"
|
||||
@@ -85,10 +85,10 @@ export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
onClick={() => onReset('skip')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 mb-4 sm:mb-0 text-sm">
|
||||
<div className="mb-4 mt-2 text-sm sm:mb-0">
|
||||
<button
|
||||
onClick={() => onReset('reset')}
|
||||
className="flex items-center gap-0.5 text-red-700 hover:text-black text-sm sm:text-base"
|
||||
className="flex items-center gap-0.5 text-sm text-red-700 hover:text-black sm:text-base"
|
||||
>
|
||||
<RefreshCcw className="mr-1 h-4" />
|
||||
Restart Asking
|
||||
|
||||
@@ -46,7 +46,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
const { response, error } = await httpGet<UserQuestionProgress>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-user-question-progress/${groupId}`
|
||||
}/v1-get-user-question-progress/${groupId}`,
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -106,7 +106,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
}/v1-reset-question-progress/${groupId}`,
|
||||
{
|
||||
status: type,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -139,7 +139,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
|
||||
async function updateQuestionStatus(
|
||||
status: QuestionProgressType,
|
||||
questionId: string
|
||||
questionId: string,
|
||||
) {
|
||||
setIsLoading(true);
|
||||
let newProgress = userProgress || { know: [], dontKnow: [], skip: [] };
|
||||
@@ -161,7 +161,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
status,
|
||||
questionId,
|
||||
questionGroupId: groupId,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -173,7 +173,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
}
|
||||
|
||||
const updatedQuestionList = pendingQuestions.filter(
|
||||
(q) => q.id !== questionId
|
||||
(q) => q.id !== questionId,
|
||||
);
|
||||
|
||||
setUserProgress(newProgress);
|
||||
@@ -198,7 +198,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
const hasFinished = !isLoading && hasProgress && !currQuestion;
|
||||
|
||||
return (
|
||||
<div className="mb-0 sm:mb-40 gap-3 text-center">
|
||||
<div className="mb-0 gap-3 text-center sm:mb-40">
|
||||
<QuestionsProgress
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
@@ -241,7 +241,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col gap-1 sm:gap-3 transition-opacity duration-300 sm:flex-row ${
|
||||
className={`flex flex-col gap-1 transition-opacity duration-300 sm:flex-row sm:gap-3 ${
|
||||
hasFinished ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
@@ -249,10 +249,10 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
disabled={isLoading || !currQuestion}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
updateQuestionStatus('know', currQuestion.id).finally(() => null);
|
||||
}}
|
||||
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50"
|
||||
className="flex flex-1 items-center rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 text-current" />
|
||||
Already Know that
|
||||
@@ -260,11 +260,11 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
<button
|
||||
onClick={() => {
|
||||
updateQuestionStatus('dontKnow', currQuestion.id).finally(
|
||||
() => null
|
||||
() => null,
|
||||
);
|
||||
}}
|
||||
disabled={isLoading || !currQuestion}
|
||||
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50"
|
||||
className="flex flex-1 items-center rounded-md border border-gray-300 bg-white px-2 py-2 text-sm text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
|
||||
>
|
||||
<Sparkles className="mr-1 h-4 text-current" />
|
||||
Didn't Know that
|
||||
@@ -275,7 +275,7 @@ export function QuestionsList(props: QuestionsListProps) {
|
||||
}}
|
||||
disabled={isLoading || !currQuestion}
|
||||
data-next-question="skip"
|
||||
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-red-600 text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50"
|
||||
className="flex flex-1 items-center rounded-md border border-red-600 px-2 py-2 text-sm text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50 sm:rounded-lg sm:px-4 sm:py-3 sm:text-base"
|
||||
>
|
||||
<SkipForward className="mr-1 h-4" />
|
||||
Skip Question
|
||||
|
||||
@@ -26,7 +26,7 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
const donePercentage = (totalSolved / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="mb-3 sm:mb-5 overflow-hidden rounded-lg border border-gray-300 bg-white p-4 sm:p-6">
|
||||
<div className="mb-3 overflow-hidden rounded-lg border border-gray-300 bg-white p-4 sm:mb-5 sm:p-6">
|
||||
<div className="mb-3 flex items-center text-gray-600">
|
||||
<div className="relative w-full flex-1 rounded-xl bg-gray-200 p-1">
|
||||
<div
|
||||
@@ -79,12 +79,12 @@ export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4" />
|
||||
Reset
|
||||
<span className='inline lg:hidden'>Progress</span>
|
||||
<span className="inline lg:hidden">Progress</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showLoginAlert && (
|
||||
<p className="-mx-6 mt-6 -mb-6 border-t bg-yellow-100 py-3 text-sm text-yellow-900">
|
||||
<p className="-mx-6 -mb-6 mt-6 border-t bg-yellow-100 py-3 text-sm text-yellow-900">
|
||||
You progress is not saved. Please{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -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,87 @@ 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 →</span>
|
||||
<span class='inline sm:hidden'>More →</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 →</span>
|
||||
<span class='inline sm:hidden'>More →</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='border-t bg-gray-100'>
|
||||
<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 →</span>
|
||||
<span class='inline sm:hidden'>More →</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
223
src/components/ShareOptions/ShareFriendList.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UserItem } from './UserItem';
|
||||
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'
|
||||
| 'sent'
|
||||
| 'received'
|
||||
| 'accepted'
|
||||
| 'rejected'
|
||||
| 'got_rejected';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListFriendsResponse = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
status: FriendshipStatus;
|
||||
roadmaps: FriendResourceProgress[];
|
||||
bestPractices: FriendResourceProgress[];
|
||||
}[];
|
||||
|
||||
type ShareFriendListProps = {
|
||||
setFriends: (friends: ListFriendsResponse) => void;
|
||||
friends: ListFriendsResponse;
|
||||
sharedFriendIds: string[];
|
||||
setSharedFriendIds: (friendIds: string[]) => void;
|
||||
};
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<ListFriendsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setFriends(response.filter((friend) => friend.status === 'accepted'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFriends().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadingFriends = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex animate-pulse items-center gap-2.5 rounded-md border p-2"
|
||||
>
|
||||
<div className="relative top-[1px] h-10 w-10 shrink-0 rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-5 w-2/4 rounded bg-gray-200" />
|
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</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) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">Select Friends to share the roadmap with</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sharedFriendIds.length === friends.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSharedFriendIds(friends.map((f) => f.userId));
|
||||
} else {
|
||||
setSharedFriendIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Select all</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<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 && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Users2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-semibold text-gray-500">
|
||||
You do not have any friends yet. <br />{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
href={`/account/friends`}
|
||||
>
|
||||
Invite your friends to share roadmaps with.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
407
src/components/ShareOptions/ShareOptionsModal.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
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';
|
||||
import { ShareOptionTabs } from './ShareOptionsTab';
|
||||
import {
|
||||
ShareTeamMemberList,
|
||||
type TeamMemberList,
|
||||
} from './ShareTeamMemberList';
|
||||
import { ShareSuccess } from './ShareSuccess';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import { Modal } from '../Modal';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
|
||||
|
||||
export type OnShareSettingsUpdate = (options: {
|
||||
isDiscoverable: boolean;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
}) => void;
|
||||
|
||||
type ShareOptionsModalProps = {
|
||||
onClose: () => void;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
isDiscoverable?: boolean;
|
||||
sharedFriendIds?: string[];
|
||||
sharedTeamMemberIds?: string[];
|
||||
teamId?: string;
|
||||
roadmapId?: string;
|
||||
description?: string;
|
||||
|
||||
onShareSettingsUpdate: OnShareSettingsUpdate;
|
||||
};
|
||||
|
||||
export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const {
|
||||
roadmapId,
|
||||
onClose,
|
||||
isDiscoverable: defaultIsDiscoverable = false,
|
||||
visibility: defaultVisibility,
|
||||
sharedTeamMemberIds: defaultSharedMemberIds = [],
|
||||
sharedFriendIds: defaultSharedFriendIds = [],
|
||||
teamId,
|
||||
onShareSettingsUpdate,
|
||||
description,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]);
|
||||
|
||||
// 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
|
||||
);
|
||||
const [sharedFriendIds, setSharedFriendIds] = useState<string[]>(
|
||||
defaultSharedFriendIds
|
||||
);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
const canTransferRoadmap = visibility === 'team' && !teamId;
|
||||
let isUpdateDisabled = false;
|
||||
// Disable update button if there are no friends to share with
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no team to transfer
|
||||
} else if (canTransferRoadmap && !selectedTeamId) {
|
||||
isUpdateDisabled = true;
|
||||
// Disable update button if there are no members to share with
|
||||
} else if (
|
||||
visibility === 'team' &&
|
||||
teamId &&
|
||||
sharedTeamMemberIds.length === 0
|
||||
) {
|
||||
isUpdateDisabled = true;
|
||||
}
|
||||
|
||||
const handleShareChange: OnShareSettingsUpdate = async ({
|
||||
sharedFriendIds,
|
||||
visibility,
|
||||
sharedTeamMemberIds,
|
||||
}) => {
|
||||
setIsLoading(true);
|
||||
|
||||
if (visibility === 'friends' && sharedFriendIds.length === 0) {
|
||||
toast.error('Please select at least one friend');
|
||||
return;
|
||||
} else if (
|
||||
visibility === 'team' &&
|
||||
teamId &&
|
||||
sharedTeamMemberIds.length === 0
|
||||
) {
|
||||
toast.error('Please select at least one member');
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPatch(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-roadmap-visibility/${roadmapId}`,
|
||||
{
|
||||
visibility,
|
||||
sharedFriendIds,
|
||||
sharedTeamMemberIds,
|
||||
isDiscoverable,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsSettingsUpdated(true);
|
||||
onShareSettingsUpdate({
|
||||
isDiscoverable,
|
||||
sharedFriendIds,
|
||||
visibility,
|
||||
sharedTeamMemberIds,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransferToTeam = useCallback(
|
||||
async (teamId: string, sharedTeamMemberIds: string[]) => {
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpPatch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
|
||||
{
|
||||
teamId,
|
||||
sharedTeamMemberIds,
|
||||
isDiscoverable,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error(error?.message || 'Something went wrong, please try again');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
},
|
||||
[roadmapId]
|
||||
);
|
||||
|
||||
if (isSettingsUpdated) {
|
||||
return (
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
wrapperClassName="max-w-lg"
|
||||
bodyClassName="p-4 flex flex-col"
|
||||
>
|
||||
<ShareSuccess
|
||||
visibility={visibility}
|
||||
roadmapId={roadmapId!}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onClose={() => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
wrapperClassName="max-w-3xl"
|
||||
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>
|
||||
<p className="text-sm text-gray-500">
|
||||
Pick and modify who can access this roadmap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ShareOptionTabs
|
||||
visibility={visibility}
|
||||
setVisibility={setVisibility}
|
||||
teamId={teamId}
|
||||
onChange={(visibility) => {
|
||||
setSelectedTeamId(null);
|
||||
|
||||
if (['me', 'public'].includes(visibility)) {
|
||||
setSharedTeamMemberIds([]);
|
||||
setSharedFriendIds([]);
|
||||
} else if (visibility === 'friends') {
|
||||
setSharedFriendIds(
|
||||
defaultSharedFriendIds.length > 0 ? defaultSharedFriendIds : []
|
||||
);
|
||||
} else if (visibility === 'team' && teamId) {
|
||||
setSharedTeamMemberIds(
|
||||
defaultSharedMemberIds?.length > 0 ? defaultSharedMemberIds : []
|
||||
);
|
||||
setSharedFriendIds([]);
|
||||
} else {
|
||||
setSharedFriendIds([]);
|
||||
setSharedTeamMemberIds([]);
|
||||
}
|
||||
|
||||
setIsDiscoverable(visibility === 'public');
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex grow flex-col">
|
||||
{visibility === 'public' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Globe2 className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Anyone with the link can access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{visibility === 'me' && (
|
||||
<div className="flex h-full flex-grow flex-col items-center justify-center rounded-md border bg-gray-50 text-center">
|
||||
<Lock className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="font-medium text-gray-500">
|
||||
Only you will be able to access.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For Personal Roadmap */}
|
||||
{visibility === 'friends' && (
|
||||
<ShareFriendList
|
||||
friends={friends}
|
||||
setFriends={setFriends}
|
||||
sharedFriendIds={sharedFriendIds}
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
<ShareTeamMemberList
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
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"
|
||||
disabled={isLoading}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={
|
||||
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
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 : [],
|
||||
sharedFriendIds:
|
||||
visibility === 'friends' ? sharedFriendIds : [],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Update Sharing Settings
|
||||
</UpdateAction>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateAction(props: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { onClick, disabled, children, className } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-w-[120px] items-center justify-center gap-1.5 rounded-md border border-gray-900 bg-gray-900 px-4 py-2 text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-75',
|
||||
disabled && 'border-gray-700 bg-gray-700 text-white hover:bg-gray-700',
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
129
src/components/ShareOptions/ShareOptionsTab.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
Check,
|
||||
Globe2,
|
||||
Lock,
|
||||
Users,
|
||||
Users2,
|
||||
} from 'lucide-react';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
export const allowedVisibilityLabels: {
|
||||
id: AllowedRoadmapVisibility;
|
||||
label: string;
|
||||
long: string;
|
||||
icon: typeof Lock;
|
||||
}[] = [
|
||||
{
|
||||
id: 'me',
|
||||
label: 'Only me',
|
||||
long: 'Only visible to me',
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
id: 'public',
|
||||
label: 'Public',
|
||||
long: 'Anyone can view',
|
||||
icon: Globe2,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
label: 'Only friends',
|
||||
long: 'Only friends can view',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: 'Only Members',
|
||||
long: 'Visible to team members',
|
||||
icon: Users2,
|
||||
},
|
||||
];
|
||||
|
||||
type ShareOptionTabsProps = {
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
setVisibility: (visibility: AllowedRoadmapVisibility) => void;
|
||||
teamId?: string;
|
||||
|
||||
onChange: (visibility: AllowedRoadmapVisibility) => void;
|
||||
};
|
||||
|
||||
export function ShareOptionTabs(props: ShareOptionTabsProps) {
|
||||
const { visibility, setVisibility, teamId, onChange } = props;
|
||||
|
||||
const handleClick = (visibility: AllowedRoadmapVisibility) => {
|
||||
setVisibility(visibility);
|
||||
onChange(visibility);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<ul className="flex w-full items-center gap-1.5">
|
||||
{allowedVisibilityLabels.map((v) => {
|
||||
if (v.id === 'friends' && teamId) {
|
||||
return null;
|
||||
} else if (v.id === 'team' && !teamId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = v.id === visibility;
|
||||
return (
|
||||
<li key={v.id}>
|
||||
<OptionTab
|
||||
label={v.label}
|
||||
isActive={isActive}
|
||||
icon={v.icon}
|
||||
onClick={() => {
|
||||
handleClick(v.id);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{!teamId && (
|
||||
<div className="grow">
|
||||
<OptionTab
|
||||
label="Transfer to team"
|
||||
icon={ArrowLeftRight}
|
||||
isActive={visibility === 'team'}
|
||||
onClick={() => {
|
||||
handleClick('team');
|
||||
}}
|
||||
className='border-red-300 text-red-600 hover:border-red-200 hover:bg-red-50 data-[active="true"]:border-red-600 data-[active="true"]:bg-red-600 data-[active="true"]:text-white'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type OptionTabProps = {
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
icon: typeof Lock;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function OptionTab(props: OptionTabProps) {
|
||||
const { label, isActive, onClick, icon: Icon, className } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-black hover:border-gray-300 hover:bg-gray-100',
|
||||
'data-[active="true"]:border-gray-500 data-[active="true"]:bg-gray-200 data-[active="true"]:text-black',
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
disabled={isActive}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!isActive && <Icon className="h-4 w-4" />}
|
||||
{isActive && <Check className="h-4 w-4" />}
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
132
src/components/ShareOptions/ShareSuccess.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/ShareOptions/ShareTeamMemberList.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { UserItem } from './UserItem';
|
||||
import { Users } from 'lucide-react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
const allowedRoles = ['admin', 'manager', 'member'] as const;
|
||||
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
|
||||
|
||||
export type AllowedMemberRoles = (typeof allowedRoles)[number];
|
||||
export type AllowedMemberStatus = (typeof allowedStatus)[number];
|
||||
|
||||
export interface TeamMemberDocument {
|
||||
_id?: string;
|
||||
userId?: string;
|
||||
invitedEmail?: string;
|
||||
teamId: string;
|
||||
role: AllowedMemberRoles;
|
||||
status: AllowedMemberStatus;
|
||||
progressReminderCount: number;
|
||||
lastProgressReminderAt?: Date;
|
||||
lastResendInviteAt?: Date;
|
||||
resendInviteCount?: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TeamMemberList extends TeamMemberDocument {
|
||||
name: string;
|
||||
avatar: string;
|
||||
hasProgress: boolean;
|
||||
}
|
||||
|
||||
type ShareTeamMemberListProps = {
|
||||
teamId: string;
|
||||
title?: string;
|
||||
sharedTeamMemberIds: string[];
|
||||
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
|
||||
|
||||
membersCache: Map<string, TeamMemberList[]>;
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
const {
|
||||
teamId,
|
||||
title = 'Select Members',
|
||||
sharedTeamMemberIds,
|
||||
setSharedTeamMemberIds,
|
||||
|
||||
membersCache,
|
||||
isTeamMembersLoading: isLoading,
|
||||
setIsTeamMembersLoading: setIsLoading,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function loadTeamMembers() {
|
||||
if (membersCache.has(teamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { response, error } = await httpGet<TeamMemberList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
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-[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">
|
||||
<div className="h-5 w-2/4 rounded bg-gray-200" />
|
||||
<div className="mt-1 h-5 w-3/4 rounded bg-gray-200" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const members = membersCache.get(teamId) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(members.length > 0 || isLoading) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">{title}</p>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sharedTeamMemberIds.length === members.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSharedTeamMemberIds(members.map((member) => member._id!));
|
||||
} else {
|
||||
setSharedTeamMemberIds([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm">Select all</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingMembers}
|
||||
{members?.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{members?.map((member) => {
|
||||
const isSelected = sharedTeamMemberIds?.includes(
|
||||
member._id?.toString()!
|
||||
);
|
||||
return (
|
||||
<li key={member.userId}>
|
||||
<UserItem
|
||||
user={{
|
||||
name: member.name,
|
||||
avatar: member.avatar,
|
||||
email: member.invitedEmail!,
|
||||
}}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSharedTeamMemberIds(
|
||||
sharedTeamMemberIds.filter(
|
||||
(id) => id !== member._id?.toString()!
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setSharedTeamMemberIds([
|
||||
...sharedTeamMemberIds,
|
||||
member._id?.toString()!,
|
||||
]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{members.length === 0 && !isLoading && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<Users className="h-12 w-12 text-gray-500" />
|
||||
<p className="text-gray-500">No members have been added yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
src/components/ShareOptions/TransferToTeamList.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { Users2 } from 'lucide-react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { UserTeamItem } from '../TeamDropdown/TeamDropdown';
|
||||
|
||||
type TransferToTeamListProps = {
|
||||
teams: UserTeamItem[];
|
||||
setTeams: (teams: UserTeamItem[]) => void;
|
||||
|
||||
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,
|
||||
isTeamMembersLoading,
|
||||
setIsTeamMembersLoading,
|
||||
onTeamChange,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function getAllTeams() {
|
||||
if (teams.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<UserTeamItem[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeams(
|
||||
response.filter((team) => ['admin', 'manager'].includes(team.role))
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getAllTeams().finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadingTeams = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<li key={index}>
|
||||
<div className="relative flex w-full items-center gap-2 rounded-md border p-2">
|
||||
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
<div className="h-4 animate-pulse rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(teams.length > 0 || isLoading) && (
|
||||
<p className="text-sm">Select a team to transfer this roadmap to</p>
|
||||
)}
|
||||
|
||||
{loadingTeams}
|
||||
{teams.length > 0 && !isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{teams.map((team) => {
|
||||
const isSelected = team._id === selectedTeamId;
|
||||
|
||||
return (
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'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={() => {
|
||||
if (isSelected) {
|
||||
setSelectedTeamId(null);
|
||||
} else {
|
||||
setSelectedTeamId(team._id);
|
||||
}
|
||||
onTeamChange(team._id);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
team.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
|
||||
team.avatar
|
||||
}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={team.name || ''}
|
||||
className="h-6 w-6 shrink-0 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
<h3 className="truncate text-left font-normal">
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{teams.length === 0 && !isLoading && (
|
||||
<div className="flex grow flex-col items-center justify-center gap-2">
|
||||
<Users2 className="h-12 w-12 text-gray-500" />
|
||||
<p className="text-gray-500">You are not a member of any team.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
src/components/ShareOptions/UserItem.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type UserItemProps = {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
onClick: () => void;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
export function UserItem(props: UserItemProps) {
|
||||
const { user, onClick, isSelected } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
|
||||
isSelected && 'border-gray-500 bg-gray-300 text-black'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
user.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={user.name || ''}
|
||||
className="relative top-[1px] h-10 w-10 shrink-0 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
<h3 className="truncate text-left font-semibold">{user.name}</h3>
|
||||
<p
|
||||
className={cn(
|
||||
'truncate text-left text-sm text-gray-500',
|
||||
isSelected && 'text-gray-700'
|
||||
)}
|
||||
>
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from 'react';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
|
||||
type StepperStep = {
|
||||
@@ -15,14 +16,14 @@ export function Stepper(props: StepperProps) {
|
||||
const { steps, activeIndex = 0, completeSteps = [] } = props;
|
||||
|
||||
return (
|
||||
<ol className="flex w-full items-center text-gray-500">
|
||||
<ol className="flex w-full items-center text-gray-500" key="stepper">
|
||||
{steps.map((step, stepCounter) => {
|
||||
const isComplete = completeSteps.includes(stepCounter);
|
||||
const isActive = activeIndex === stepCounter;
|
||||
const isLast = stepCounter === (steps.length - 1);
|
||||
const isLast = stepCounter === steps.length - 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fragment key={stepCounter}>
|
||||
<li
|
||||
className={`flex items-center ${
|
||||
isComplete || isActive ? 'text-black' : 'text-gray-400'
|
||||
@@ -43,7 +44,7 @@ export function Stepper(props: StepperProps) {
|
||||
<span className={'h-1 w-full'} />
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||