Compare commits
1 Commits
feat/share
...
chore/cach
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47926c496d |
@@ -1,3 +1,2 @@
|
||||
PUBLIC_API_URL=https://api.roadmap.sh
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
|
||||
@@ -14,12 +14,24 @@ 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 Link
|
||||
description: Please create the roadmap [using our roadmap editor](https://twitter.com/kamrify/status/1708293162693767426) and submit the roadmap link.
|
||||
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).
|
||||
placeholder: |
|
||||
https://roadmap.sh/xyz
|
||||
- Item 1
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
- Item 2
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
9
.github/workflows/deploy.yml
vendored
@@ -4,23 +4,21 @@ 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@v4
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Prepare Draw Repository
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
@@ -29,7 +27,6 @@ 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@v4
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
5
.gitignore
vendored
@@ -1,5 +1,4 @@
|
||||
.idea
|
||||
.temp
|
||||
|
||||
# build output
|
||||
dist/
|
||||
@@ -28,7 +27,3 @@ pnpm-debug.log*
|
||||
/playwright/.cache/
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
/renderer/*
|
||||
!/renderer/index.tsx
|
||||
!/renderer/renderer.ts
|
||||
@@ -1,4 +1,5 @@
|
||||
// https://astro.build/config
|
||||
import preact from '@astrojs/preact';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
@@ -7,8 +8,6 @@ import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
import react from '@astrojs/react';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://roadmap.sh/',
|
||||
@@ -32,9 +31,11 @@ export default defineConfig({
|
||||
'https://cs.fyi',
|
||||
'https://roadmap.sh',
|
||||
];
|
||||
|
||||
if (whiteListedStarts.some((start) => href.startsWith(start))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return 'noopener noreferrer nofollow';
|
||||
},
|
||||
},
|
||||
@@ -45,6 +46,22 @@ export default defineConfig({
|
||||
format: 'file',
|
||||
},
|
||||
integrations: [
|
||||
{
|
||||
name: 'client-authenticated',
|
||||
hooks: {
|
||||
'astro:config:setup'(options) {
|
||||
options.addClientDirective({
|
||||
name: 'authenticated',
|
||||
entrypoint: fileURLToPath(
|
||||
new URL(
|
||||
'./src/directives/client-authenticated.mjs',
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tailwind({
|
||||
config: {
|
||||
applyBaseStyles: false,
|
||||
@@ -55,10 +72,9 @@ export default defineConfig({
|
||||
serialize: serializeSitemap,
|
||||
}),
|
||||
compress({
|
||||
HTML: false,
|
||||
CSS: false,
|
||||
JavaScript: false,
|
||||
css: false,
|
||||
js: false,
|
||||
}),
|
||||
react(),
|
||||
preact(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -30,12 +30,11 @@ Find [the content directory inside the relevant roadmap](https://github.com/kamr
|
||||
|
||||
## Guidelines
|
||||
|
||||
- <p><strong>Adding everything available out there is not the goal!</strong><br />
|
||||
- <p><strong>Adding everything available out there is not the goal!</strong><br />
|
||||
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn?! There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
|
||||
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
|
||||
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
|
||||
Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included. Have you read this book? Can you give a short article?</p>
|
||||
- <p><strong>Create a Single PR for Content Additions</strong></p>
|
||||
|
||||
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./src/data/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
|
||||
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./content/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
|
||||
- Write meaningful commit messages
|
||||
- Look at the existing issues/pull requests before opening new ones
|
||||
|
||||
11028
package-lock.json
generated
29
package.json
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 3000",
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
@@ -18,44 +18,31 @@
|
||||
"roadmap-content": "node scripts/roadmap-content.cjs",
|
||||
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
||||
"best-practice-content": "node scripts/best-practice-content.cjs",
|
||||
"generate-renderer": "sh scripts/generate-renderer.sh",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^3.0.0",
|
||||
"@astrojs/preact": "^2.2.1",
|
||||
"@astrojs/sitemap": "^1.3.3",
|
||||
"@astrojs/tailwind": "^5.0.0",
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@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",
|
||||
"clsx": "^2.0.0",
|
||||
"dracula-prism": "^2.1.13",
|
||||
"@nanostores/preact": "^0.5.0",
|
||||
"astro": "^2.6.6",
|
||||
"astro-compress": "^1.1.47",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.274.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"nanostores": "^0.9.2",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.0.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"reactflow": "^11.8.3",
|
||||
"preact": "^10.15.1",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.3.3"
|
||||
"tailwindcss": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
||||
2614
pnpm-lock.yaml
generated
@@ -1,8 +0,0 @@
|
||||
<svg width="63" height="24" viewBox="0 0 63 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="63" height="24" rx="7" fill="#563AFF"/>
|
||||
<path d="M27.2629 16.7273H25.2856L28.2984 8H30.6763L33.6848 16.7273H31.7075L29.5214 9.99432H29.4533L27.2629 16.7273ZM27.1393 13.2969H31.8098V14.7372H27.1393V13.2969Z" fill="white"/>
|
||||
<path d="M37.829 16.7273H34.7352V8H37.8545C38.7324 8 39.4881 8.17472 40.1216 8.52415C40.7551 8.87074 41.2423 9.36932 41.5832 10.0199C41.927 10.6705 42.0989 11.4489 42.0989 12.3551C42.0989 13.2642 41.927 14.0455 41.5832 14.6989C41.2423 15.3523 40.7523 15.8537 40.1131 16.2031C39.4767 16.5526 38.7153 16.7273 37.829 16.7273ZM36.5804 15.1463H37.7523C38.2977 15.1463 38.7565 15.0497 39.1287 14.8565C39.5037 14.6605 39.7849 14.358 39.9724 13.9489C40.1628 13.5369 40.2579 13.0057 40.2579 12.3551C40.2579 11.7102 40.1628 11.1832 39.9724 10.7741C39.7849 10.3651 39.5051 10.0639 39.1329 9.87074C38.7608 9.67756 38.302 9.58097 37.7565 9.58097H36.5804V15.1463Z" fill="white"/>
|
||||
<path d="M46.5594 16.7273H43.4657V8H46.585C47.4628 8 48.2185 8.17472 48.8521 8.52415C49.4856 8.87074 49.9728 9.36932 50.3137 10.0199C50.6574 10.6705 50.8293 11.4489 50.8293 12.3551C50.8293 13.2642 50.6574 14.0455 50.3137 14.6989C49.9728 15.3523 49.4827 15.8537 48.8435 16.2031C48.2072 16.5526 47.4458 16.7273 46.5594 16.7273ZM45.3109 15.1463H46.4827C47.0282 15.1463 47.487 15.0497 47.8592 14.8565C48.2342 14.6605 48.5154 14.358 48.7029 13.9489C48.8932 13.5369 48.9884 13.0057 48.9884 12.3551C48.9884 11.7102 48.8932 11.1832 48.7029 10.7741C48.5154 10.3651 48.2356 10.0639 47.8634 9.87074C47.4913 9.67756 47.0324 9.58097 46.487 9.58097H45.3109V15.1463Z" fill="white"/>
|
||||
<path d="M10 12H18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 8V16" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="89" height="24" rx="7" fill="black"/>
|
||||
<path d="M23.8217 17V7.54545H27.5518C28.2659 7.54545 28.8752 7.67318 29.38 7.92862C29.8878 8.18099 30.274 8.53954 30.5387 9.00426C30.8065 9.46591 30.9403 10.0091 30.9403 10.6339C30.9403 11.2617 30.8049 11.8018 30.5341 12.2543C30.2633 12.7036 29.8709 13.0483 29.3569 13.2884C28.846 13.5284 28.2274 13.6484 27.5011 13.6484H25.0036V12.0419H27.1779C27.5595 12.0419 27.8765 11.9896 28.1289 11.8849C28.3813 11.7803 28.569 11.6233 28.6921 11.4141C28.8183 11.2048 28.8814 10.9447 28.8814 10.6339C28.8814 10.32 28.8183 10.0553 28.6921 9.83984C28.569 9.62441 28.3797 9.46129 28.1243 9.3505C27.8719 9.23662 27.5534 9.17969 27.1687 9.17969H25.8207V17H23.8217ZM28.9276 12.6974L31.2773 17H29.0707L26.7717 12.6974H28.9276ZM32.353 17V7.54545H38.7237V9.19354H34.3519V11.4464H38.396V13.0945H34.3519V15.3519H38.7422V17H32.353ZM40.3129 7.54545H42.7781L45.3818 13.8977H45.4926L48.0963 7.54545H50.5615V17H48.6226V10.8462H48.5441L46.0974 16.9538H44.7771L42.3303 10.8232H42.2519V17H40.3129V7.54545ZM60.8967 12.2727C60.8967 13.3037 60.7012 14.1809 60.3104 14.9041C59.9226 15.6274 59.3932 16.1798 58.7223 16.5614C58.0545 16.94 57.3035 17.1293 56.4695 17.1293C55.6293 17.1293 54.8752 16.9384 54.2074 16.5568C53.5395 16.1752 53.0117 15.6228 52.6239 14.8995C52.2362 14.1763 52.0423 13.3007 52.0423 12.2727C52.0423 11.2417 52.2362 10.3646 52.6239 9.64134C53.0117 8.91809 53.5395 8.36719 54.2074 7.98864C54.8752 7.60701 55.6293 7.41619 56.4695 7.41619C57.3035 7.41619 58.0545 7.60701 58.7223 7.98864C59.3932 8.36719 59.9226 8.91809 60.3104 9.64134C60.7012 10.3646 60.8967 11.2417 60.8967 12.2727ZM58.87 12.2727C58.87 11.6049 58.77 11.0417 58.57 10.5831C58.373 10.1245 58.0945 9.77675 57.7344 9.53977C57.3743 9.30279 56.9527 9.1843 56.4695 9.1843C55.9863 9.1843 55.5646 9.30279 55.2045 9.53977C54.8445 9.77675 54.5644 10.1245 54.3643 10.5831C54.1674 11.0417 54.0689 11.6049 54.0689 12.2727C54.0689 12.9406 54.1674 13.5038 54.3643 13.9624C54.5644 14.4209 54.8445 14.7687 55.2045 15.0057C55.5646 15.2427 55.9863 15.3612 56.4695 15.3612C56.9527 15.3612 57.3743 15.2427 57.7344 15.0057C58.0945 14.7687 58.373 14.4209 58.57 13.9624C58.77 13.5038 58.87 12.9406 58.87 12.2727ZM63.5523 7.54545L65.8374 14.7287H65.9252L68.2149 7.54545H70.4308L67.1716 17H64.5956L61.3318 7.54545H63.5523ZM71.5688 17V7.54545H77.9395V9.19354H73.5677V11.4464H77.6118V13.0945H73.5677V15.3519H77.958V17H71.5688Z" fill="white"/>
|
||||
<path d="M8 12L17 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 773 KiB |
|
Before Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 345 KiB |
@@ -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://roadmap.sh/questions">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Questions-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
<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>
|
||||
<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" />
|
||||
@@ -35,9 +35,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Python Roadmap](https://roadmap.sh/python)
|
||||
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
|
||||
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
|
||||
- [JavaScript Roadmap](https://roadmap.sh/javascript)
|
||||
@@ -51,6 +49,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [GraphQL Roadmap](https://roadmap.sh/graphql)
|
||||
- [Android Roadmap](https://roadmap.sh/android)
|
||||
- [Flutter Roadmap](https://roadmap.sh/flutter)
|
||||
- [Python Roadmap](https://roadmap.sh/python)
|
||||
- [Go Roadmap](https://roadmap.sh/golang)
|
||||
- [Java Roadmap](https://roadmap.sh/java)
|
||||
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/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 renderer
|
||||
mkdir renderer
|
||||
|
||||
# copy the files at /src/editor/renderer/* to /renderer
|
||||
# while replacing any existing files
|
||||
cp -rf .temp/web-draw/src/editor/renderer/* renderer
|
||||
|
||||
# Add @ts-nocheck to the top of each ts and tsx file
|
||||
# so that the typescript compiler doesn't complain
|
||||
# about the missing types
|
||||
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
|
||||
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 renderer directory
|
||||
git update-index --skip-worktree renderer/*
|
||||
@@ -53,12 +53,12 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
|
||||
const sortOrder = controlName.match(/^\d+/)?.[0];
|
||||
|
||||
// No directory for a group without control name
|
||||
if (!controlName || (!sortOrder && !controlName.startsWith('check:'))) {
|
||||
if (!controlName || !sortOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
// e.g. testing-your-apps:other-options
|
||||
const controlNameWithoutSortOrder = controlName.replace(/^\d+-/, '').replace(/^check:/, '');
|
||||
const controlNameWithoutSortOrder = controlName.replace(/^\d+-/, '');
|
||||
// e.g. ['testing-your-apps', 'other-options']
|
||||
const dirParts = controlNameWithoutSortOrder.split(':');
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
---
|
||||
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;
|
||||
@@ -23,32 +21,11 @@ const sidebarLinks = [
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
id: 'road-card',
|
||||
isNew: false,
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'badge',
|
||||
classes: 'h-4 w-4',
|
||||
@@ -89,17 +66,17 @@ const sidebarLinks = [
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href='/team'
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
activePageId === 'team' ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
<!--<li>-->
|
||||
<!-- <a-->
|
||||
<!-- href='/team'-->
|
||||
<!-- class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${-->
|
||||
<!-- activePageId === 'team' ? 'bg-slate-100' : ''-->
|
||||
<!-- }`}-->
|
||||
<!-- >-->
|
||||
<!-- <AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />-->
|
||||
<!-- Teams-->
|
||||
<!-- </a>-->
|
||||
<!--</li>-->
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
@@ -112,16 +89,10 @@ const sidebarLinks = [
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
{sidebarLink.icon.component ? (
|
||||
<sidebarLink.icon.component
|
||||
className={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
) : (
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
)}
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
@@ -154,29 +125,18 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
{sidebarLink.icon.component ? (
|
||||
<sidebarLink.icon.component
|
||||
className={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
) : (
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
)}
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
{sidebarLink.isNew &&
|
||||
!isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{sidebarLink.id === 'friends' && (
|
||||
<SidebarFriendsCounter client:load />
|
||||
{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>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
@@ -189,12 +149,7 @@ const sidebarLinks = [
|
||||
}
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
'grow px-0 py-0 md:py-10',
|
||||
{ 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar },
|
||||
]}
|
||||
>
|
||||
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,11 +21,11 @@ function ActivityCounter(props: ActivityCounterType) {
|
||||
const { text, count } = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
|
||||
<h2 className="text-base sm:text-5xl font-bold">
|
||||
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
|
||||
<h2 class="text-base sm:text-5xl font-bold">
|
||||
{count}
|
||||
</h2>
|
||||
<p className="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
|
||||
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export function ActivityCounters(props: ActivityCountersType) {
|
||||
const { done, learning, streak } = props;
|
||||
|
||||
return (
|
||||
<div className="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
|
||||
<div className="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
|
||||
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
|
||||
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
|
||||
<ActivityCounter
|
||||
text={'Topics Completed'}
|
||||
count={`${done?.total || 0}`}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ActivityCounters } from './ActivityCounters';
|
||||
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;
|
||||
@@ -24,9 +13,24 @@ export type ActivityResponse = {
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
roadmaps: ProgressResponse[];
|
||||
bestPractices: ProgressResponse[];
|
||||
customs: ProgressResponse[];
|
||||
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;
|
||||
}[];
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
@@ -87,16 +91,16 @@ export function ActivityPage() {
|
||||
streak={activity?.streak || { count: 0 }}
|
||||
/>
|
||||
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
{learningRoadmaps.length === 0 &&
|
||||
learningBestPractices.length === 0 && <EmptyActivity />}
|
||||
|
||||
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
|
||||
<>
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
<h2 class="mb-3 text-xs uppercase text-gray-400">
|
||||
Continue Following
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -106,8 +110,6 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
key={roadmap.id}
|
||||
isCustomResource={roadmap.isCustomResource}
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
@@ -134,8 +136,6 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((bestPractice) => (
|
||||
<ResourceProgress
|
||||
isCustomResource={bestPractice.isCustomResource}
|
||||
key={bestPractice.id}
|
||||
doneCount={bestPractice.done || 0}
|
||||
totalCount={bestPractice.total || 0}
|
||||
learningCount={bestPractice.learning || 0}
|
||||
|
||||
@@ -2,21 +2,21 @@ import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
|
||||
export function EmptyActivity() {
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<div class="rounded-md">
|
||||
<div class="flex flex-col items-center p-7 text-center">
|
||||
<img
|
||||
alt="no roadmaps"
|
||||
src={RoadmapIcon.src}
|
||||
className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
|
||||
src={RoadmapIcon}
|
||||
class="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
|
||||
/>
|
||||
<h2 className="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<h2 class="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
|
||||
Progress will appear here as you start tracking your{' '}
|
||||
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
|
||||
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href="/best-practices" className="mt-4 text-blue-500 hover:underline">
|
||||
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
|
||||
Best Practices
|
||||
</a>{' '}
|
||||
progress.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
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';
|
||||
@@ -16,17 +14,14 @@ type ResourceProgressType = {
|
||||
skippedCount: number;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true, isCustomResource } = props;
|
||||
const { showClearButton = true } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const userId = getUser()?.id;
|
||||
|
||||
const {
|
||||
updatedAt,
|
||||
resourceType,
|
||||
@@ -56,8 +51,8 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
@@ -66,15 +61,11 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
}
|
||||
}
|
||||
|
||||
let url =
|
||||
const url =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}`
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
if (isCustomResource) {
|
||||
url = `/r?id=${resourceId}`;
|
||||
}
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
|
||||
@@ -97,7 +88,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
{getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</a>
|
||||
<div className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<span className="hidden flex-1 gap-1 sm:flex">
|
||||
{doneCount > 0 && (
|
||||
<>
|
||||
@@ -116,56 +107,44 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
|
||||
<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"
|
||||
/>
|
||||
<span className={'hidden sm:block'}>•</span>
|
||||
{showClearButton && (
|
||||
<>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showClearButton && (
|
||||
<>
|
||||
{!isConfirming && (
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { type OptionType, SearchSelector } from './SearchSelector';
|
||||
import { OptionType, SearchSelector } from './SearchSelector';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { httpPut } from '../lib/http';
|
||||
@@ -65,15 +65,15 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
)?.title;
|
||||
|
||||
return (
|
||||
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<div class="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner isDualRing={false} className="h-4 w-4" />
|
||||
<h2 className="font-medium">Loading...</h2>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
{!isLoading && !error && selectedRoadmap && (
|
||||
<div className={'text-center'}>
|
||||
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
|
||||
<h3 className="mb-1.5 text-2xl font-medium">
|
||||
<h3 class="mb-1.5 text-2xl font-medium">
|
||||
{selectedRoadmapTitle} Added
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-none text-gray-400">
|
||||
@@ -95,11 +95,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
to make changes to the roadmap.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
>
|
||||
+ Add More
|
||||
</button>
|
||||
@@ -119,14 +119,14 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<>
|
||||
<h3 className="mb-1.5 text-2xl font-medium">Error</h3>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
|
||||
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -135,7 +135,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
)}
|
||||
{!isLoading && !error && !selectedRoadmap && (
|
||||
<>
|
||||
<h3 className="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400">
|
||||
Search and add a roadmap
|
||||
</p>
|
||||
@@ -156,11 +156,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
placeholder={'Search for roadmap'}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
export function EmailLoginForm() {
|
||||
const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleFormSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
@@ -29,7 +29,6 @@ export function EmailLoginForm() {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.reload();
|
||||
|
||||
@@ -77,7 +76,7 @@ export function EmailLoginForm() {
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
<p className="mb-3 mt-2 text-sm text-gray-500">
|
||||
<p class="mb-3 mt-2 text-sm text-gray-500">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-blue-800 hover:text-blue-600"
|
||||
@@ -99,4 +98,6 @@ export function EmailLoginForm() {
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default EmailLoginForm;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function EmailSignupForm() {
|
||||
const EmailSignupForm: FunctionComponent = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
@@ -9,7 +10,7 @@ export function EmailSignupForm() {
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const onSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
@@ -97,4 +98,6 @@ export function EmailSignupForm() {
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default EmailSignupForm;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
@@ -7,7 +7,7 @@ export function ForgotPasswordForm() {
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
@@ -29,7 +29,7 @@ export function ForgotPasswordForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<form onSubmit={handleSubmit} class="w-full">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import GitHubIcon from '../../icons/github.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import Cookies from 'js-cookie';
|
||||
@@ -56,18 +57,11 @@ 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, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
@@ -96,11 +90,10 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
// 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 =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
|
||||
@@ -112,14 +105,14 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
src={icon}
|
||||
alt="GitHub"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import GoogleIcon from '../../icons/google.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
@@ -55,18 +55,11 @@ 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, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
@@ -92,11 +85,10 @@ 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 =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
|
||||
@@ -113,14 +105,14 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
src={icon}
|
||||
alt="Google"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
@@ -55,18 +55,11 @@ 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, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
@@ -92,11 +85,10 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
// 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 =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
|
||||
@@ -113,14 +105,14 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
src={icon}
|
||||
alt="Google"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import { EmailLoginForm } from './EmailLoginForm';
|
||||
import EmailLoginForm from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
@@ -9,9 +9,9 @@ import { LinkedInButton } from './LinkedInButton';
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<p class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
</p>
|
||||
</h2>
|
||||
<p class='mt-2 text-sm leading-4 text-slate-600'>
|
||||
You must be logged in to perform this action.
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
export default function ResetPasswordForm() {
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
@@ -21,7 +21,7 @@ export function ResetPasswordForm() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
@@ -56,7 +56,6 @@ export function ResetPasswordForm() {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import ErrorIcon from '../../icons/error.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import ErrorIcon from '../../icons/error.svg';
|
||||
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function TriggerVerifyAccount() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -29,7 +30,6 @@ export function TriggerVerifyAccount() {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = '/';
|
||||
})
|
||||
@@ -58,14 +58,14 @@ export function TriggerVerifyAccount() {
|
||||
{isLoading && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={SpinnerIcon.src}
|
||||
className={'mx-auto h-16 w-16 animate-spin'}
|
||||
src={SpinnerIcon}
|
||||
class={'mx-auto h-16 w-16 animate-spin'}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={ErrorIcon.src}
|
||||
src={ErrorIcon}
|
||||
className={'mx-auto h-16 w-16'}
|
||||
/>
|
||||
)}
|
||||
@@ -74,7 +74,7 @@ export function TriggerVerifyAccount() {
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
{isLoading && <p>Please wait while we verify your account..</p>}
|
||||
{error && <p className="text-red-700">{error}</p>}
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import VerifyLetterIcon from '../../icons/verify-letter.svg';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function VerificationEmailMessage() {
|
||||
@@ -39,13 +39,13 @@ export function VerificationEmailMessage() {
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<img
|
||||
alt="Verify Email"
|
||||
src={VerifyLetterIcon.src}
|
||||
className="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
src={VerifyLetterIcon}
|
||||
class="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
/>
|
||||
<h2 className="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
Verify your email address
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
<div class="text-sm sm:text-base">
|
||||
<p>
|
||||
We have sent you an email at{' '}
|
||||
<span className="font-bold">{email}</span>. Please click the link to
|
||||
@@ -53,7 +53,7 @@ export function VerificationEmailMessage() {
|
||||
soon!
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<hr class="my-4" />
|
||||
|
||||
{!isEmailResent && (
|
||||
<>
|
||||
@@ -72,12 +72,12 @@ export function VerificationEmailMessage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-700">{error}</p>}
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEmailResent && (
|
||||
<p className="text-green-700">Verification email has been sent!</p>
|
||||
<p class="text-green-700">Verification email has been sent!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,16 +36,14 @@ function handleGuest() {
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/roadmaps',
|
||||
'/account/road-card',
|
||||
'/account/friends',
|
||||
'/account',
|
||||
'/team',
|
||||
'/team/progress',
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/settings',
|
||||
'/team/settings'
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
@@ -73,10 +71,7 @@ function handleAuthenticated() {
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
const authRedirect = window.localStorage.getItem('authRedirect') || '/';
|
||||
window.localStorage.removeItem('authRedirect');
|
||||
|
||||
window.location.href = authRedirect;
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpDelete, httpGet, httpPost } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
import { showLoginPopup } from '../lib/popup';
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { DeleteUserIcon } from './ReactIcons/DeleteUserIcon';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { useAuth } from '../hooks/use-auth';
|
||||
import { AddedUserIcon } from './ReactIcons/AddedUserIcon';
|
||||
import { StopIcon } from './ReactIcons/StopIcon';
|
||||
import { ErrorIcon } from './ReactIcons/ErrorIcon';
|
||||
|
||||
export type FriendshipStatus =
|
||||
| 'none'
|
||||
| 'sent'
|
||||
| 'received'
|
||||
| 'accepted'
|
||||
| 'rejected'
|
||||
| 'got_rejected';
|
||||
|
||||
type UserResponse = {
|
||||
id: string;
|
||||
links: Record<string, string>;
|
||||
avatar: string;
|
||||
name: string;
|
||||
status: FriendshipStatus;
|
||||
};
|
||||
|
||||
export function Befriend() {
|
||||
const { u: inviteId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const currentUser = useAuth();
|
||||
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [user, setUser] = useState<UserResponse>();
|
||||
const isAuthenticated = isLoggedIn();
|
||||
|
||||
async function loadUser(userId: string) {
|
||||
const { response, error } = await httpGet<UserResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-friend/${userId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'accepted') {
|
||||
window.location.href = '/account/friends?c=fa';
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteId) {
|
||||
loadUser(inviteId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setError('Missing invite ID in URL');
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
}, [inviteId]);
|
||||
|
||||
async function addFriend(userId: string, successMessage: string) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
setError('');
|
||||
const { response, error } = await httpPost<UserResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'accepted') {
|
||||
window.location.href = '/account/friends?c=fa';
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(response);
|
||||
}
|
||||
|
||||
async function deleteFriend(userId: string, successMessage: string) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
setError('');
|
||||
const { response, error } = await httpDelete<UserResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(response);
|
||||
toast.success(successMessage);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container text-center">
|
||||
<ErrorIcon additionalClasses="mx-auto mb-4 mt-24 w-20 opacity-20" />
|
||||
|
||||
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
|
||||
<p className="mb-4 text-base leading-6 text-gray-600">
|
||||
{error || 'There was a problem, please try again.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userAvatar = user.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const isMe = currentUser?.id === user.id;
|
||||
|
||||
return (
|
||||
<div className="container !max-w-[400px] text-center">
|
||||
<img
|
||||
alt={'join team'}
|
||||
src={userAvatar}
|
||||
className="mx-auto mb-4 mt-24 w-28 rounded-full"
|
||||
/>
|
||||
|
||||
<h2 className={'mb-1 text-3xl font-bold'}>{user.name}</h2>
|
||||
<p className="mb-6 text-base leading-6 text-gray-600">
|
||||
After you add {user.name} as a friend, you will be able to view each
|
||||
other's skills and progress.
|
||||
</p>
|
||||
|
||||
<div className="mx-auto w-full duration-500 sm:max-w-md">
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
{user.status === 'none' && (
|
||||
<button
|
||||
disabled={isMe}
|
||||
onClick={() => {
|
||||
if (!isAuthenticated) {
|
||||
return showLoginPopup();
|
||||
}
|
||||
|
||||
addFriend(user.id, 'Friend request sent').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
className="w-full flex-grow cursor-pointer rounded-lg bg-black px-3 py-2 text-center text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{isMe ? "You can't add yourself" : 'Add Friend'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.status === 'sent' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
|
||||
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Request Sent
|
||||
</span>
|
||||
|
||||
{!isConfirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
type="button"
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
|
||||
>
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
|
||||
Withdraw Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
deleteFriend(user.id, 'Friend request withdrawn').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
className="ml-2 text-red-600 underline"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'accepted' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
|
||||
<AddedUserIcon additionalClasses="mr-2 h-5 w-5" />
|
||||
You are friends
|
||||
</span>
|
||||
|
||||
{!isConfirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
type="button"
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
|
||||
>
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
|
||||
Remove Friend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
deleteFriend(user.id, 'Friend removed').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
className="ml-2 text-red-600 underline"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'rejected' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Request Rejected
|
||||
</span>
|
||||
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Changed your mind?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
addFriend(user.id, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Click here to Accept
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'got_rejected' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-500 px-3 py-2 text-center text-red-500">
|
||||
<StopIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Request Rejected
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'received' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
addFriend(user.id, 'Friend request accepted').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-gray-800 bg-gray-800 px-3 py-2 text-center text-white hover:bg-black"
|
||||
>
|
||||
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Accept Request
|
||||
</button>
|
||||
|
||||
{!isConfirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
type="button"
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-white px-3 py-2 text-center text-red-600 hover:bg-red-100"
|
||||
>
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
|
||||
Reject Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
deleteFriend(user.id, 'Friend request rejected').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
className="ml-2 text-red-600 underline"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
export interface Props {
|
||||
bestPracticeId: string;
|
||||
}
|
||||
const { bestPracticeId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
|
||||
<ResourceProgressStats resourceId={bestPracticeId} resourceType='best-practice' />
|
||||
<ResourceProgressStats />
|
||||
</div>
|
||||
|
||||
44
src/components/Breadcrumbs.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import type { BreadcrumbItem } from '../lib/roadmap-topic';
|
||||
|
||||
export interface Props {
|
||||
breadcrumbs: BreadcrumbItem[];
|
||||
roadmapId: string;
|
||||
}
|
||||
|
||||
const { breadcrumbs, roadmapId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='py-7 pb-6'>
|
||||
<!-- Desktop breadcrumbs -->
|
||||
<p class='text-gray-500 container hidden sm:block'>
|
||||
{
|
||||
breadcrumbs.map((breadcrumb, counter) => {
|
||||
const isLast = counter === breadcrumbs.length - 1;
|
||||
|
||||
if (!isLast) {
|
||||
return (
|
||||
<>
|
||||
<a class='hover:text-gray-800' href={`${breadcrumb.url}`}>
|
||||
{breadcrumb.title}
|
||||
</a>
|
||||
<span> · </span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span class='text-gray-400'>{breadcrumb.title}</span>;
|
||||
})
|
||||
}
|
||||
</p>
|
||||
|
||||
<!-- Mobile breadcrums -->
|
||||
<p class='container block sm:hidden'>
|
||||
<a
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
href={`/${roadmapId}`}
|
||||
>
|
||||
← Back to Topics List
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||
import ClipboardIcon from '../../icons/clipboard.svg';
|
||||
import GuideIcon from '../../icons/guide.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
@@ -19,17 +18,16 @@ export type PageType = {
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon.src },
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
id: 'account',
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon.src,
|
||||
icon: UserIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -37,15 +35,7 @@ const defaultPages: PageType[] = [
|
||||
url: '/team',
|
||||
title: 'Teams',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
url: '/account/friends',
|
||||
title: 'Friends',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
icon: GroupIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -53,43 +43,28 @@ const defaultPages: PageType[] = [
|
||||
url: '/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
},
|
||||
{
|
||||
id: 'account-roadmaps',
|
||||
url: '/account/roadmaps',
|
||||
title: 'Custom Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
isProtected: true,
|
||||
icon: RoadmapIcon,
|
||||
},
|
||||
{
|
||||
id: 'best-practices',
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon.src,
|
||||
},
|
||||
{
|
||||
id: 'questions',
|
||||
url: '/questions',
|
||||
title: 'Questions',
|
||||
group: 'Pages',
|
||||
icon: ClipboardIcon.src,
|
||||
icon: BestPracticesIcon,
|
||||
},
|
||||
{
|
||||
id: 'guides',
|
||||
url: '/guides',
|
||||
title: 'Guides',
|
||||
group: 'Pages',
|
||||
icon: GuideIcon.src,
|
||||
icon: GuideIcon,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
url: '/videos',
|
||||
title: 'Videos',
|
||||
group: 'Pages',
|
||||
icon: VideoIcon.src,
|
||||
icon: VideoIcon,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -182,12 +157,12 @@ export function CommandMenu() {
|
||||
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus={true}
|
||||
autofocus={true}
|
||||
type="text"
|
||||
value={searchedText}
|
||||
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
|
||||
placeholder="Search roadmaps, guides or pages .."
|
||||
autoComplete="off"
|
||||
autocomplete="off"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
setSearchedText(value);
|
||||
@@ -215,43 +190,39 @@ export function CommandMenu() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="px-2 py-2">
|
||||
<div class="px-2 py-2">
|
||||
<div className="flex flex-col">
|
||||
{searchResults.length === 0 && (
|
||||
<div className="p-5 text-center text-sm text-gray-400">
|
||||
<div class="p-5 text-center text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.map((page: PageType, counter: number) => {
|
||||
{searchResults.map((page, counter) => {
|
||||
const prevPage = searchResults[counter - 1];
|
||||
const groupChanged = prevPage && prevPage.group !== page.group;
|
||||
|
||||
return (
|
||||
<Fragment key={page.id}>
|
||||
<>
|
||||
{groupChanged && (
|
||||
<div className="border-b border-gray-100"></div>
|
||||
<div class="border-b border-gray-100"></div>
|
||||
)}
|
||||
<a
|
||||
className={`flex w-full items-center rounded p-2 text-sm ${
|
||||
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
href={page.url}
|
||||
>
|
||||
{!page.icon && (
|
||||
<span className="mr-2 text-gray-400">{page.group}</span>
|
||||
<span class="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img
|
||||
alt={page.title}
|
||||
src={page.icon}
|
||||
className="mr-2 h-4 w-4"
|
||||
/>
|
||||
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{page.title}
|
||||
</a>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactConfetti from 'react-confetti';
|
||||
|
||||
type ConfettiPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
type ConfettiProps = {
|
||||
pieces?: number;
|
||||
element?: HTMLElement | null;
|
||||
onDone?: () => void;
|
||||
};
|
||||
|
||||
export function Confetti(props: ConfettiProps) {
|
||||
const { element = document.body, onDone = () => null, pieces = 40 } = props;
|
||||
|
||||
const [confettiPos, setConfettiPos] = useState<
|
||||
undefined | ConfettiPosition
|
||||
>();
|
||||
|
||||
function populateConfettiPosition(element: HTMLElement) {
|
||||
const elRect = element.getBoundingClientRect();
|
||||
|
||||
// set confetti position, keeping in mind the scroll values
|
||||
setConfettiPos({
|
||||
x: elRect?.x || 0,
|
||||
y: (elRect?.y || 0) + window.scrollY,
|
||||
w: elRect?.width || 0,
|
||||
h: elRect?.height || 0,
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) {
|
||||
setConfettiPos(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
populateConfettiPosition(element);
|
||||
}, [element]);
|
||||
|
||||
if (!confettiPos) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactConfetti
|
||||
height={document.body.scrollHeight}
|
||||
numberOfPieces={pieces}
|
||||
recycle={false}
|
||||
onConfettiComplete={(confettiInstance) => {
|
||||
setConfettiPos(undefined);
|
||||
onDone();
|
||||
}}
|
||||
initialVelocityX={4}
|
||||
initialVelocityY={8}
|
||||
tweenDuration={10}
|
||||
confettiSource={{
|
||||
x: confettiPos.x,
|
||||
y: confettiPos.y,
|
||||
w: confettiPos.w,
|
||||
h: confettiPos.h,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Stepper } from '../Stepper';
|
||||
import { Step0, type ValidTeamType } from './Step0';
|
||||
import { Step1, type ValidTeamSize } from './Step1';
|
||||
import { Step0, ValidTeamType } from './Step0';
|
||||
import { Step1, ValidTeamSize } from './Step1';
|
||||
import { Step2 } from './Step2';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
@@ -190,14 +190,14 @@ export function CreateTeamForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'mx-auto max-w-[700px] py-1 md:py-6'}>
|
||||
<div className={'mb-3 md:mb-8 pb-3 md:pb-0 border-b md:border-b-0 flex flex-col items-start md:items-center'}>
|
||||
<h1 className={'text-xl md:text-4xl font-bold'}>Create Team</h1>
|
||||
<p className={'mt-1 md:mt-2 text-sm md:text-base text-gray-500'}>
|
||||
<div className={'mx-auto max-w-[700px] py-6'}>
|
||||
<div className={'mb-8 flex flex-col items-center'}>
|
||||
<h1 className={'text-4xl font-bold'}>Create Team</h1>
|
||||
<p className={'mt-2 text-gray-500'}>
|
||||
Complete the steps below to create your team
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-8 mt-8 hidden sm:flex w-full">
|
||||
<div className="mb-8 mt-8 flex w-full">
|
||||
<Stepper
|
||||
activeIndex={stepIndex}
|
||||
completeSteps={completedSteps}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function NextButton(props: NextButtonProps) {
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type as any}
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
|
||||
type NotDropdownProps = {
|
||||
onClick: () => void;
|
||||
selectedCount: number;
|
||||
singularName: string;
|
||||
pluralName: string;
|
||||
};
|
||||
|
||||
export function NotDropdown(props: NotDropdownProps) {
|
||||
const { onClick, selectedCount, singularName, pluralName } = props;
|
||||
|
||||
const singularOrPlural = selectedCount === 1 ? singularName : pluralName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-text items-center justify-between rounded-md border border-gray-300 px-3 py-2.5 hover:border-gray-400/50 hover:bg-gray-50"
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="mb-1.5 text-base font-medium text-gray-800">
|
||||
{selectedCount} {singularOrPlural} selected
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Click to add or change selection
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-base text-gray-400">
|
||||
Click to select {pluralName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={singularName}
|
||||
src={ChevronDownIcon.src}
|
||||
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { SearchSelector } from '../SearchSelector';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import SearchIcon from '../../icons/search.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||
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;
|
||||
teamResources: TeamResourceConfig;
|
||||
setTeamResources: (config: TeamResourceConfig) => void;
|
||||
team: TeamDocument;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { teamId, teamResources = [], setTeamResources } = props;
|
||||
const { team, teamResourceConfig = [], setTeamResourceConfig } = 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;
|
||||
}
|
||||
@@ -68,15 +50,15 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting resource`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-delete-team-resource-config/${teamId}`,
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
@@ -88,7 +70,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
@@ -100,17 +82,17 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
}
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
if (!team?._id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team-resource-config/${
|
||||
team._id
|
||||
}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
teamId: team._id,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
@@ -122,25 +104,13 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResources(response);
|
||||
setTeamResourceConfig(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 && (
|
||||
@@ -148,195 +118,102 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResources}
|
||||
teamId={team?._id!}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
defaultRemovedItems={
|
||||
teamResources.find((c) => c.resourceId === changingRoadmapId)
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="my-3 flex items-center gap-4">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={teamId}
|
||||
onClose={() => setIsCreatingRoadmap(false)}
|
||||
onCreated={(roadmap: RoadmapDocument) => {
|
||||
handleCustomRoadmapCreated(roadmap);
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
<SearchSelector
|
||||
placeholder={`Search Roadmaps ..`}
|
||||
onSelect={(option) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
options={allRoadmaps
|
||||
.filter((roadmap) => {
|
||||
return !teamResourceConfig
|
||||
.map((c) => c.resourceId)
|
||||
.includes(roadmap.id);
|
||||
})
|
||||
.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
searchInputId={'roadmap-input'}
|
||||
inputClassName="mt-2 block w-full rounded-md border px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
/>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<div className="mt-4 rounded-md border px-4 py-12 text-center text-sm text-gray-700">
|
||||
<img
|
||||
alt={'search'}
|
||||
src={SearchIcon}
|
||||
className={'mx-auto mb-5 h-[42px] w-[42px] opacity-10'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{!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>
|
||||
.
|
||||
<span className="block text-lg font-semibold text-black">
|
||||
No roadmaps selected.
|
||||
</span>
|
||||
<p className={'text-sm text-gray-400'}>
|
||||
Please search and add roadmaps from above
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
|
||||
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>
|
||||
{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>
|
||||
|
||||
{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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
const allowedRoles = [
|
||||
@@ -86,7 +86,10 @@ export function RoleDropdown(props: RoleDropdownProps) {
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`capitalize`}>
|
||||
className={`capitalize ${
|
||||
selectedRole === 'admin' ? 'text-blue-600' : ''
|
||||
} ${selectedRole === 'manager' ? 'text-cyan-600' : ''}`}
|
||||
>
|
||||
{selectedRole || 'Select Role'}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
|
||||
|
||||
export type SelectRoadmapModalProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
onRoadmapAdd: (roadmapId: string) => void;
|
||||
onRoadmapRemove: (roadmapId: string) => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
const {
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
onRoadmapAdd,
|
||||
onRoadmapRemove,
|
||||
teamResourceConfig,
|
||||
} = props;
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const searchInputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(allRoadmaps);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInputEl.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputEl.current.focus();
|
||||
}, [searchInputEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText.length === 0) {
|
||||
setSearchResults(allRoadmaps);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = allRoadmaps.filter((roadmap) => {
|
||||
return (
|
||||
roadmap.title.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
roadmap.id.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
});
|
||||
setSearchResults(searchResults);
|
||||
}, [searchText, allRoadmaps]);
|
||||
|
||||
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('role-roadmap')
|
||||
);
|
||||
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('skill-roadmap')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
|
||||
<span className="sr-only">Close modal</span>
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputEl}
|
||||
type="text"
|
||||
placeholder="Search roadmaps"
|
||||
className="block w-full border-b px-5 pb-3.5 pt-4 outline-none placeholder:text-gray-400"
|
||||
value={searchText}
|
||||
onInput={(e) => setSearchText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="min-h-[200px] p-4">
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Role Based Roadmaps
|
||||
</span>
|
||||
{roleBasedRoadmaps.length === 0 && (
|
||||
<p className="mb-1 flex h-full items-start text-sm italic text-gray-400"></p>
|
||||
)}
|
||||
{roleBasedRoadmaps.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig?.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
key={roadmap.id}
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Skill Based Roadmaps
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{skillBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
key={roadmap.id}
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { SelectRoadmapModalProps } from './SelectRoadmapModal';
|
||||
|
||||
type SelectRoadmapModalItemProps = {
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModalItem(props: SelectRoadmapModalItemProps) {
|
||||
const { isSelected, onClick, title } = props;
|
||||
return (
|
||||
<button
|
||||
className={`group flex min-h-[35px] items-stretch overflow-hidden rounded-md text-sm ${
|
||||
!isSelected
|
||||
? 'border border-gray-300 hover:bg-gray-100'
|
||||
: 'bg-black text-white transition-colors hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center px-3">{title}</span>
|
||||
{isSelected && (
|
||||
<span className="flex items-center bg-gray-700 px-3 text-xs text-white transition-colors">
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isSelected && (
|
||||
<span className="flex items-center bg-gray-100 px-2.5 text-xs text-gray-500">
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,23 +2,21 @@ import BuildingIcon from '../../icons/building.svg';
|
||||
import UsersIcon from '../../icons/users.svg';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { useState } from 'react';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamTypes = [
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon.src,
|
||||
description:
|
||||
'Track the skills and learning progress of the tech team at your company',
|
||||
icon: BuildingIcon,
|
||||
description: 'Use roadmap.sh for your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon.src,
|
||||
description:
|
||||
'Invite your friends or course-mates and track your learning progress together',
|
||||
icon: UsersIcon,
|
||||
description: 'Invite your friends and learn together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -72,11 +70,10 @@ export function Step0(props: Step0Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col gap-3 sm:flex-row'}>
|
||||
<div className={'flex flex-row gap-3'}>
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
key={validTeamType.value}
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
||||
validTeamType.value == selectedTeamType
|
||||
? 'border-gray-400 bg-gray-100'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
@@ -84,17 +81,16 @@ 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 ${
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="mb-2 block text-2xl font-bold">
|
||||
<span className="mb-1 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm leading-[21px] text-gray-500">
|
||||
<span className="text-sm text-gray-500">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
@@ -104,11 +100,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 items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<a
|
||||
href="/account"
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-center text-red-500'
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { type AppError, httpPost, httpPut } from '../../lib/http';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AppError, httpPost, httpPut } from '../../lib/http';
|
||||
import type { ValidTeamType } from './Step0';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamSizes = [
|
||||
'1-5',
|
||||
'6-10',
|
||||
'11-25',
|
||||
'26-50',
|
||||
'51-100',
|
||||
'101-200',
|
||||
'0-1',
|
||||
'2-10',
|
||||
'11-50',
|
||||
'51-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
@@ -49,7 +47,7 @@ export function Step1(props: Step1Props) {
|
||||
team?.teamSize || ('' as any)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !selectedTeamType) {
|
||||
@@ -124,7 +122,7 @@ export function Step1(props: Step1Props) {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="name"
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
|
||||
@@ -133,10 +131,10 @@ export function Step1(props: Step1Props) {
|
||||
type="text"
|
||||
name="name"
|
||||
ref={nameRef as any}
|
||||
autoFocus={true}
|
||||
autofocus={true}
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Roadmap Inc."
|
||||
placeholder="roadmap.sh"
|
||||
disabled={isLoading}
|
||||
required
|
||||
value={name}
|
||||
@@ -147,7 +145,7 @@ export function Step1(props: Step1Props) {
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="website"
|
||||
for="website"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Website
|
||||
@@ -168,8 +166,8 @@ export function Step1(props: Step1Props) {
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
Company LinkedIn URL
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -187,7 +185,7 @@ export function Step1(props: Step1Props) {
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
GitHub Organization URL
|
||||
</label>
|
||||
<input
|
||||
@@ -205,10 +203,10 @@ export function Step1(props: Step1Props) {
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="team-size"
|
||||
for="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Tech Team Size
|
||||
Company Size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
@@ -221,23 +219,17 @@ export function Step1(props: Step1Props) {
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
<option value="" selected>
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option key={size} value={size}>{size} people</option>
|
||||
<option value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-between gap-2 md:flex-row">
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoadmapSelector, type TeamResourceConfig } from './RoadmapSelector';
|
||||
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step2Props = {
|
||||
@@ -17,22 +17,21 @@ export function Step2(props: Step2Props) {
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-1 text-lg font-bold md:mb-1.5 md:text-2xl">
|
||||
Select Roadmaps
|
||||
</h2>
|
||||
<h2 className="mb-2 text-2xl font-bold">Select Roadmaps</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
You can always add and customize your roadmaps later.
|
||||
Picks the roadmaps to be made available to your team for tracking.
|
||||
You can always add more later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RoadmapSelector
|
||||
teamId={team._id!}
|
||||
teamResources={teamResourceConfig}
|
||||
setTeamResources={setTeamResourceConfig}
|
||||
team={team}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -43,30 +42,17 @@ export function Step2(props: Step2Props) {
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
|
||||
<div className={'flex gap-2'}>
|
||||
<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 disabled:opacity-50 disabled:pointer-events-none'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={teamResourceConfig.length === 0}
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
Next Step
|
||||
<span className="ml-1">→</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={teamResourceConfig.length === 0}
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
Next Step
|
||||
<span className="ml-1">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { type AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type Step3Props = {
|
||||
@@ -75,9 +75,9 @@ export function Step3(props: Step3Props) {
|
||||
|
||||
return (
|
||||
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
<div class="mb-1 mt-2">
|
||||
<h2 class="mb-2 text-2xl font-bold">Invite your Team</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Use the form below to invite your team members to your team. You can
|
||||
also invite them later.
|
||||
</p>
|
||||
@@ -85,10 +85,10 @@ export function Step3(props: Step3Props) {
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{users.map((user, userCounter) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-2" key={user.id}>
|
||||
<div className="flex flex-row gap-2" key={user.id}>
|
||||
<input
|
||||
ref={userCounter === users.length - 1 ? emailInputRef : null}
|
||||
autoFocus={true}
|
||||
autofocus={true}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
@@ -163,7 +163,7 @@ export function Step3(props: Step3Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-row items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -178,9 +178,8 @@ 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 disabled:opacity-50 disabled:pointer-events-none'
|
||||
'rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
|
||||
@@ -8,15 +8,15 @@ type Step4Props = {
|
||||
export function Step4({ team }: Step4Props) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
|
||||
<div className="mb-1 flex flex-col items-center">
|
||||
<div class="mb-1 flex flex-col items-center">
|
||||
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
|
||||
<h2 className="mb-2 text-2xl font-bold">Team Created</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
<h2 class="mb-2 text-2xl font-bold">Team Created</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
Your team has been created. Happy learning!
|
||||
</p>
|
||||
<a
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
className="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
||||
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
||||
>
|
||||
View Team
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
@@ -65,9 +65,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
};
|
||||
}, [removedItems]);
|
||||
|
||||
let resourceJsonUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
@@ -146,12 +144,11 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div class="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 class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
|
||||
<div
|
||||
id={'customized-roadmap'}
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative rounded-lg bg-white shadow"
|
||||
class="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
@@ -192,10 +189,10 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resource-svg-wrap" ref={containerEl} className="px-4"></div>
|
||||
<div ref={containerEl} className="px-4"></div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-center">
|
||||
<div class="flex w-full justify-center">
|
||||
<Spinner
|
||||
isDualRing={false}
|
||||
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
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;
|
||||
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 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 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>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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 { RoadmapRenderer } from './RoadmapRenderer';
|
||||
import { TopicDetail } from '../TopicDetail/TopicDetail';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { currentRoadmap } from '../../stores/roadmap';
|
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { RestrictedPage } from './RestrictedPage';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
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 />
|
||||
<RoadmapRenderer roadmap={roadmap!} />
|
||||
<TopicDetail canSubmitContribution={false} />
|
||||
<UserProgressModal
|
||||
resourceId={roadmap?._id!}
|
||||
resourceType="roadmap"
|
||||
isCustomResource={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {CircleSlash, PenSquare, Shapes} from 'lucide-react';
|
||||
|
||||
type EmptyRoadmapProps = {
|
||||
roadmapId: string;
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
export function EmptyRoadmap(props: EmptyRoadmapProps) {
|
||||
const { roadmapId, canManage } = props;
|
||||
const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`;
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<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 rounded-md bg-gray-500 px-4 py-2 font-medium text-white hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<Shapes className="inline-block mr-2 h-4 w-4" />
|
||||
Edit Roadmap
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import {
|
||||
ExternalLink,
|
||||
Shapes,
|
||||
type LucideIcon,
|
||||
Globe,
|
||||
LockIcon,
|
||||
Users,
|
||||
} 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
|
||||
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_110px]"
|
||||
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={`/r?id=${roadmap._id}`}
|
||||
className={
|
||||
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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
|
||||
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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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 bg-gray-500 py-1.5 pl-2 pr-2 text-xs font-medium text-white hover:bg-gray-600 sm:pl-1.5 sm:pr-3 sm:text-sm"
|
||||
>
|
||||
<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 z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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';
|
||||
|
||||
type RoadmapHeaderProps = {};
|
||||
|
||||
export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
_id: roadmapId,
|
||||
creator,
|
||||
team,
|
||||
} = useStore(currentRoadmap) || {};
|
||||
|
||||
const [isSharing, setIsSharing] = 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';
|
||||
|
||||
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 && (
|
||||
<>
|
||||
in
|
||||
<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>
|
||||
{$canManageCurrentRoadmap && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isSharing && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RoadmapHint
|
||||
roadmapTitle={title!}
|
||||
hasTNSBanner={false}
|
||||
roadmapId={roadmapId!}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
svg text tspan {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'],
|
||||
svg > g[data-type='subtopic'],
|
||||
svg > g > g[data-type='link-item'],
|
||||
svg > g[data-type='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic']:hover > rect {
|
||||
fill: #d6d700;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtopic']:hover > rect {
|
||||
fill: #f3c950;
|
||||
}
|
||||
svg > g[data-type='button']:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text,
|
||||
svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
svg > g[data-type='topic'].learning > rect + text,
|
||||
svg > g[data-type='topic'].done > rect + text {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
svg > g[data-type='subtipic'].done > rect + text,
|
||||
svg > g[data-type='subtipic'].learning > rect + text {
|
||||
fill: #cbcbcb;
|
||||
}
|
||||
|
||||
svg .learning rect {
|
||||
fill: #dad1fd !important;
|
||||
}
|
||||
svg .learning text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69 !important;
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Renderer } from '../../../renderer';
|
||||
import './RoadmapRenderer.css';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
updateResourceProgress,
|
||||
type ResourceProgressType,
|
||||
renderTopicProgress,
|
||||
refreshProgressCounters,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { EmptyRoadmap } from './EmptyRoadmap';
|
||||
|
||||
type RoadmapRendererProps = {
|
||||
roadmap: RoadmapDocument;
|
||||
};
|
||||
|
||||
type RoadmapNodeDetails = {
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
targetGroup: SVGElement;
|
||||
};
|
||||
|
||||
export function getNodeDetails(
|
||||
svgElement: SVGElement
|
||||
): RoadmapNodeDetails | null {
|
||||
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
|
||||
|
||||
const nodeId = targetGroup?.dataset?.nodeId;
|
||||
const nodeType = targetGroup?.dataset?.type;
|
||||
if (!nodeId || !nodeType) return null;
|
||||
|
||||
return { nodeId, nodeType, targetGroup };
|
||||
}
|
||||
|
||||
export const allowedClickableNodeTypes = [
|
||||
'topic',
|
||||
'subtopic',
|
||||
'button',
|
||||
'link-item',
|
||||
];
|
||||
|
||||
export function RoadmapRenderer(props: RoadmapRendererProps) {
|
||||
const { roadmap } = props;
|
||||
const roadmapRef = useRef<HTMLDivElement>(null);
|
||||
const roadmapId = roadmap._id!;
|
||||
|
||||
const toast = useToast();
|
||||
const [hideRenderer, setHideRenderer] = useState(false);
|
||||
|
||||
async function updateTopicStatus(
|
||||
topicId: string,
|
||||
newStatus: ResourceProgressType
|
||||
) {
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
refreshProgressCounters();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSvgClick = useCallback((e: MouseEvent) => {
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
|
||||
return;
|
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') {
|
||||
const link = targetGroup?.dataset?.link || '';
|
||||
const isExternalLink = link.startsWith('http');
|
||||
if (isExternalLink) {
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
updateTopicStatus(
|
||||
nodeId,
|
||||
isCurrentStatusLearning ? 'pending' : 'learning'
|
||||
);
|
||||
return;
|
||||
} else if (e.altKey) {
|
||||
e.preventDefault();
|
||||
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('roadmap.node.click', {
|
||||
detail: {
|
||||
topicId: nodeId,
|
||||
resourceId: roadmap?._id,
|
||||
resourceType: 'roadmap',
|
||||
isCustomResource: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSvgRightClick = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const target = e.target as SVGElement;
|
||||
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
|
||||
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
|
||||
return;
|
||||
|
||||
if (nodeType === 'button' || nodeType === 'link-item') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCurrentStatusDone = targetGroup?.classList.contains('done');
|
||||
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roadmapRef?.current) return;
|
||||
roadmapRef?.current?.addEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
|
||||
|
||||
return () => {
|
||||
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
|
||||
roadmapRef?.current?.removeEventListener(
|
||||
'contextmenu',
|
||||
handleSvgRightClick
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
|
||||
<div className="container !max-w-[1000px]">
|
||||
<Renderer
|
||||
ref={roadmapRef}
|
||||
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
|
||||
onRendered={() => {
|
||||
renderResourceProgress('roadmap', roadmapId).then(() => {
|
||||
if (roadmap?.nodes?.length === 0) {
|
||||
setHideRenderer(true);
|
||||
roadmapRef?.current?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{hideRenderer && (
|
||||
<EmptyRoadmap roadmapId={roadmapId} canManage={roadmap.canManage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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,4 +1,4 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import {useEffect, useState} from 'preact/hooks';
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { logout } from '../Navigation/navigation';
|
||||
|
||||
@@ -10,9 +10,9 @@ export function DeleteAccountForm() {
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
@@ -53,7 +53,7 @@ export function DeleteAccountForm() {
|
||||
type="text"
|
||||
name="delete-account"
|
||||
id="delete-account"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 py-2 px-3 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Type "delete" to confirm'}
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -12,6 +12,6 @@ import { DeleteAccountForm } from './DeleteAccountForm';
|
||||
|
||||
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
|
||||
|
||||
<DeleteAccountForm client:only="react" />
|
||||
<DeleteAccountForm client:only />
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpDelete } from '../lib/http';
|
||||
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
|
||||
import { useTeamId } from '../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { useKeydown } from '../hooks/use-keydown';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
|
||||
type DeleteTeamPopupProps = {
|
||||
onClose: () => void;
|
||||
@@ -13,7 +12,6 @@ type DeleteTeamPopupProps = {
|
||||
export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -34,7 +32,7 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
inputEl.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
@@ -55,7 +53,6 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Team deleted successfully');
|
||||
window.location.href = '/account';
|
||||
};
|
||||
|
||||
@@ -69,18 +66,18 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div class="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold text-black">Delete Team</h2>
|
||||
<p className="text-gray-500">
|
||||
This will permanently delete your team and all associated data.
|
||||
<p>
|
||||
This will permanently delete your account and all your associated
|
||||
data including your progress.
|
||||
</p>
|
||||
|
||||
<p className="-mb-2 mt-3 text-base font-medium text-black">
|
||||
<p class="-mb-2 mt-3 text-base font-medium text-black">
|
||||
Please type "delete" to confirm.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
|
||||
@@ -22,7 +22,7 @@ if (faqs.length === 0) {
|
||||
<div class='border-t bg-gray-100 mt-8'>
|
||||
<div class='container'>
|
||||
<div class='flex justify-between relative -top-5'>
|
||||
<h2 class='text-sm sm:text-base font-medium py-1 px-3 border bg-white rounded-md'>Frequently Asked Questions</h2>
|
||||
<h1 class='text-sm sm:text-base font-medium py-1 px-3 border bg-white rounded-md'>Frequently Asked Questions</h1>
|
||||
</div>
|
||||
|
||||
<div class='flex flex-col gap-1 pb-14'>
|
||||
|
||||
@@ -16,8 +16,8 @@ export function FavoriteIcon(props: FavoriteIconProps) {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5ZM5.93682 1.25C6.42732 1.25 6.83382 1.632 6.86382 2.122L7.24932 8.506C7.25216 8.55018 7.24229 8.59425 7.22089 8.63301C7.19949 8.67176 7.16745 8.70359 7.12854 8.72472C7.08964 8.74585 7.0455 8.75542 7.00134 8.75228C6.95718 8.74914 6.91484 8.73343 6.87932 8.707L4.27582 6.783C4.19591 6.72397 4.09917 6.69211 3.99982 6.69211C3.90047 6.69211 3.80373 6.72397 3.72382 6.783L1.11982 8.707C1.0843 8.73343 1.04196 8.74914 0.9978 8.75228C0.953639 8.75542 0.909502 8.74585 0.8706 8.72472C0.831697 8.70359 0.799653 8.67176 0.778252 8.63301C0.756851 8.59425 0.746986 8.55018 0.749822 8.506L1.13632 2.122C1.16632 1.632 1.57232 1.25 2.06282 1.25H5.93682Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
@@ -35,8 +35,8 @@ export function FavoriteIcon(props: FavoriteIconProps) {
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
@@ -6,18 +6,11 @@ export interface FeaturedItemType {
|
||||
isNew?: boolean;
|
||||
url: string;
|
||||
text: string;
|
||||
allowBookmark?: boolean;
|
||||
}
|
||||
|
||||
export interface Props extends FeaturedItemType {}
|
||||
|
||||
const {
|
||||
isUpcoming = false,
|
||||
isNew = false,
|
||||
text,
|
||||
url,
|
||||
allowBookmark = true,
|
||||
} = Astro.props;
|
||||
const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
@@ -33,17 +26,11 @@ const {
|
||||
{text}
|
||||
</span>
|
||||
|
||||
{
|
||||
allowBookmark && (
|
||||
<MarkFavorite
|
||||
resourceId={url.split('/').pop()!}
|
||||
resourceType={
|
||||
url.includes('best-practices') ? 'best-practice' : 'roadmap'
|
||||
}
|
||||
client:only='react'
|
||||
/>
|
||||
)
|
||||
}
|
||||
<MarkFavorite
|
||||
resourceId={url.split('/').pop()!}
|
||||
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
|
||||
client:load
|
||||
/>
|
||||
|
||||
{
|
||||
isNew && (
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
---
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import FeaturedItem, { type FeaturedItemType } from './FeaturedItem.astro';
|
||||
import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
|
||||
|
||||
export interface Props {
|
||||
featuredItems: FeaturedItemType[];
|
||||
heading: string;
|
||||
showCreateRoadmap?: boolean;
|
||||
allowBookmark?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
featuredItems,
|
||||
heading,
|
||||
showCreateRoadmap,
|
||||
allowBookmark = true,
|
||||
} = Astro.props;
|
||||
const { featuredItems, heading } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
|
||||
<div class='container'>
|
||||
<h2
|
||||
class='text-md font-regular absolute -top-[17px] flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2'
|
||||
class='text-md font-regular absolute flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 -top-[17px] sm:left-1/2 sm:-translate-x-1/2'
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
@@ -30,7 +22,6 @@ const {
|
||||
featuredItems.map((featuredItem) => (
|
||||
<li>
|
||||
<FeaturedItem
|
||||
allowBookmark={allowBookmark}
|
||||
text={featuredItem.text}
|
||||
url={featuredItem.url}
|
||||
isNew={featuredItem.isNew}
|
||||
@@ -39,13 +30,6 @@ const {
|
||||
</li>
|
||||
))
|
||||
}
|
||||
{
|
||||
showCreateRoadmap && (
|
||||
<li>
|
||||
<CreateRoadmapButton client:load className='min-h-[54px]' />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
@@ -21,16 +20,14 @@ export function MarkFavorite({
|
||||
favorite,
|
||||
className,
|
||||
}: MarkFavoriteType) {
|
||||
const isAuthenticated = isLoggedIn();
|
||||
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(
|
||||
isAuthenticated ? (favorite ?? localStorage.getItem(localStorageKey) === '1') : false
|
||||
favorite || false
|
||||
);
|
||||
|
||||
async function toggleFavoriteHandler(e: MouseEvent<HTMLButtonElement>) {
|
||||
async function toggleFavoriteHandler(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
@@ -84,7 +81,6 @@ export function MarkFavorite({
|
||||
} = (e as CustomEvent).detail;
|
||||
if (id === resourceId && type === resourceType) {
|
||||
setIsFavorite(fav);
|
||||
localStorage.setItem(localStorageKey, fav ? '1' : '0');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,7 +92,6 @@ export function MarkFavorite({
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
onClick={toggleFavoriteHandler}
|
||||
tabIndex={-1}
|
||||
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useTeamId } from '../../hooks/use-team-id';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
|
||||
type SubmitFeedbackPopupProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function SubmitFeedbackPopup(props: SubmitFeedbackPopupProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const inputEl = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [feedbackText, setFeedbackText] = useState('');
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputEl.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ status: 'ok' }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-submit-team-feedback/${teamId}`,
|
||||
{
|
||||
feedback: feedbackText,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSuccess(true);
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
setFeedbackText('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
{!isSuccess && (
|
||||
<>
|
||||
<h2 className="mb-1 text-xl font-semibold text-black">
|
||||
Enter your feedback
|
||||
</h2>
|
||||
<p className={'text-sm text-gray-500'}>
|
||||
Help us improve your experience.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="my-4">
|
||||
<textarea
|
||||
ref={inputEl}
|
||||
name="submit-feedback"
|
||||
id="submit-feedback"
|
||||
className="mt-2 block min-h-[150px] w-full resize-none rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400"
|
||||
placeholder="Enter your feedback"
|
||||
required
|
||||
autoFocus
|
||||
value={feedbackText}
|
||||
onInput={(e) =>
|
||||
setFeedbackText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-md bg-red-100 p-2 text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-md bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={isLoading}
|
||||
type="submit"
|
||||
className="flex-grow cursor-pointer rounded-md bg-black py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="flex w-full flex-col items-center">
|
||||
<CheckIcon additionalClasses="w-14 h-14 text-green-500 mt-4" />
|
||||
<h1 className="mt-4 text-xl font-semibold text-black sm:text-2xl">
|
||||
Feedback Submitted
|
||||
</h1>
|
||||
<p className="text-center text-sm text-gray-500 sm:text-base">
|
||||
Thank you for submitting your feedback.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClosePopup}
|
||||
className="mt-4 w-full flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,30 +68,18 @@ import Icon from './AstroIcon.astro';
|
||||
<a href='/privacy' class='hover:text-white'>Privacy</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
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='ml-2 hover:text-white'
|
||||
class='hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
|
||||
</a>
|
||||
<a
|
||||
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>
|
||||
|
||||
@@ -28,9 +28,6 @@ svg .clickable-group:hover > [fill='rgb(255,255,0)'] {
|
||||
svg .clickable-group:hover > [fill='rgb(255,229,153)'] {
|
||||
fill: #f3c950;
|
||||
}
|
||||
svg .clickable-group:hover > [stroke='rgb(255,229,153)'] {
|
||||
stroke: #f3c950;
|
||||
}
|
||||
|
||||
svg .clickable-group:hover > [fill='rgb(153,153,153)'] {
|
||||
fill: #646464;
|
||||
@@ -52,12 +49,7 @@ svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done rect[stroke="rgb(255,229,153)"] {
|
||||
stroke: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text,
|
||||
svg .skipped text {
|
||||
svg .done text, svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -66,7 +58,7 @@ svg .learning rect {
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69 !important;
|
||||
fill: #496b69!important;
|
||||
}
|
||||
|
||||
svg .learning rect[fill='rgb(51,51,51)'] + text,
|
||||
@@ -80,7 +72,7 @@ svg .learning text {
|
||||
|
||||
svg .clickable-group.done[data-group-id^='check:'] rect {
|
||||
fill: gray !important;
|
||||
stroke: gray !important;
|
||||
stroke: gray;
|
||||
}
|
||||
|
||||
.clickable-group rect {
|
||||
@@ -96,10 +88,8 @@ svg .removed text {
|
||||
fill: #9c9c9c !important;
|
||||
}
|
||||
|
||||
svg .removed g,
|
||||
svg .removed circle,
|
||||
svg .removed path {
|
||||
opacity: 0;
|
||||
svg .removed g, svg .removed circle, svg .removed path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/************************************
|
||||
@@ -132,14 +122,3 @@ svg .removed path {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#customized-roadmap #resource-svg-wrap g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap circle,
|
||||
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
|
||||
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*.clickable-group:hover {*/
|
||||
/* cursor: url(/images/cursors/add.svg) 5 5, move;*/
|
||||
/*}*/
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
refreshProgressCounters,
|
||||
renderResourceProgress,
|
||||
renderTopicProgress,
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
@@ -227,8 +228,18 @@ export class Renderer {
|
||||
}
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
let status: ResourceProgressType = 'pending';
|
||||
if (targetGroup.classList.contains('done')) {
|
||||
status = 'done';
|
||||
} else if (targetGroup.classList.contains('learning')) {
|
||||
status = 'learning';
|
||||
} else if (targetGroup.classList.contains('skipped')) {
|
||||
status = 'skipped';
|
||||
} else if (targetGroup.classList.contains('removed')) {
|
||||
status = 'removed';
|
||||
}
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
if (status === 'removed') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -262,6 +273,7 @@ export class Renderer {
|
||||
topicId: groupId.replace('check:', ''),
|
||||
resourceType: this.resourceType,
|
||||
resourceId: this.resourceId,
|
||||
status,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -299,6 +311,7 @@ export class Renderer {
|
||||
topicId: normalizedGroupId,
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
status,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||