Compare commits

...

303 Commits

Author SHA1 Message Date
Kamran Ahmed
7f5f96a6b7 Merge branch 'master' into chore/update-progress 2023-07-26 15:43:34 +01:00
Kamran Ahmed
14f9ad9530 Highlight user personal progress 2023-07-26 15:41:39 +01:00
Kamran Ahmed
076b866430 Personal progress indicator in teams 2023-07-26 15:24:29 +01:00
Kamran Ahmed
7aca57c3e4 Team roadmaps listing page 2023-07-25 21:34:00 +01:00
Kamran Ahmed
36cd03f14f Use the same add roadmap modal 2023-07-25 20:50:40 +01:00
Kamran Ahmed
5bc33cb527 Member progress item sorting 2023-07-25 20:05:47 +01:00
Kamran Ahmed
5d3202e065 Add skip button for teams 2023-07-25 18:56:40 +01:00
Kamran Ahmed
5cf286a753 Update team sizes and copy 2023-07-25 18:32:43 +01:00
Kamran Ahmed
0addc56123 Update the select roadmaps modal 2023-07-25 18:24:32 +01:00
Arik Chakma
3182e2a599 Show current user progress first (#4255)
* wip: progress sorting

* chore: show current user progress first

* fix: team guard

* fix: user progress sort
2023-07-25 17:36:49 +01:00
Arik Chakma
ff96644751 chore: current user header 2023-07-25 22:21:56 +06:00
Kamran Ahmed
8c7fb8cab5 Copy change 2023-07-25 16:51:34 +01:00
Arik Chakma
f61d360ee7 Add select roadmap modal (#4253)
* wip: roadmap selector modal

* wip

* fix: typo

* fix: prettier

* chore: close icon
2023-07-25 16:49:21 +01:00
Arik Chakma
081e16eb9b chore: show tracking for current user 2023-07-25 21:44:02 +06:00
Arik Chakma
eefce5c6a5 chore: add update progress in modal 2023-07-25 21:27:18 +06:00
Kamran Ahmed
29d91be094 Add cursors 2023-07-25 13:21:57 +01:00
Kamran Ahmed
8ee56576ea Update copy for team creation 2023-07-25 13:21:56 +01:00
Arik Chakma
8e945f5e1c Leave Team confirmation popup (#4254)
* wip: leave team popup

* fix: leave warning
2023-07-24 23:38:49 +01:00
Kamran Ahmed
ac48f4c441 Enable teams 2023-07-24 17:26:44 +01:00
Kamran Ahmed
34d0cde165 fix: toast not appearing 2023-07-20 19:55:45 +01:00
Kamran Ahmed
03ba0c384b Add teams support 2023-07-20 19:24:34 +01:00
Arik Chakma
bbe8125fc1 chore: add youtube and twitter icon (#4241) 2023-07-20 17:50:06 +01:00
Balaji Sivasakthi
0c64223ec1 fix(typo): package manager's C++ Archive Network (cppan) heading (#4118)
* fix(typo): package manager's C++ Archive Network (cppan) heading

* Update pnpm-lock.yaml
2023-07-15 13:30:16 +06:00
Nicolas Walcker de Miranda
3a022926de fix check gzip compression url (#4208)
* fix: change check gzip compression url

* remove package-lock.json
2023-07-14 18:32:47 +01:00
Balaji Sivasakthi
93a6ae3f81 fix(typo): fixed typo in cpp - main() function heading (#4120) 2023-07-14 21:57:48 +06:00
Balaji Sivasakthi
42b3595367 fix(typo): fixed typo in cpp namespaces (#4122) 2023-07-14 21:56:21 +06:00
Sadegh Motevali
39278cc97b fix incorrect pyramid document URL (#4198) 2023-07-14 21:54:51 +06:00
Kyrylo Nehaturov
c83d20d63c Fix: removed dublicate link in 108-javascript-expressions-and-operators (#4196)
Removed dublicate link in JS roadmap
2023-07-12 12:57:09 +01:00
Kamran Ahmed
6e8770c8c4 Add clone note in readme 2023-07-11 15:21:12 +01:00
Tabish Naqvi
3457f7495a Clone size fix ISSUE #3312 (#4130)
* Issue #4110 Broken Link Fix

* added note in readme.md fixing large clone size Issue #3312
2023-07-11 15:18:21 +01:00
Dmitrii Goriachev
07acb17459 Update links to new react documentation (#4134)
* update react error boundary link

* update links to JSX

* actualize Component Life Cycle overview and links

* update links to event handling in react

* actualize HOC overview
2023-07-11 14:03:28 +01:00
Davidson Fellipe
77cd0ecf26 Fix typos (#4135) 2023-07-11 04:12:00 +01:00
Reyhan4j02
eccc0302f2 Update 100-installing.md (#4143)
Previously the MinGW-64 link redirected to an error 404 page 
Fixed it to redirect to the overview page
2023-07-11 04:11:22 +01:00
Kamran Ahmed
7274d8a54e Add new badge to sql roadmap 2023-07-11 04:00:31 +01:00
Kamran Ahmed
8d19be6232 Fix typos in ux design roadmap 2023-07-11 03:55:42 +01:00
Ritik Ranjan
e0828d11bf Remove trailing spaces/lines (#4177) 2023-07-11 03:54:03 +01:00
Andrei Belokurov
9e7a37d079 Fix invalid link in devops roadmap (#4186)
Updated from 'ext_link:roadmap.sh/python' to 'ext_link:roadmap.sh/backend' and 'ext_link:roadmap.sh/best-practices/aws'.
2023-07-11 03:53:46 +01:00
Kamran Ahmed
76f1592615 Add link to SQL roadmap 2023-07-11 03:53:17 +01:00
Kamran Ahmed
80e80e7d9b Add syntax highlighting for queries 2023-07-11 03:47:48 +01:00
Kamran Ahmed
8692f05f14 Add content for SQL roadmap 2023-07-11 03:45:54 +01:00
Kamran Ahmed
e5705bd6cc Add SQL roadmap 2023-07-10 20:36:53 +01:00
Arik Chakma
f52e6df410 fix: twice social callback call 2023-07-10 21:18:37 +06:00
Kamran Ahmed
c4db994753 Add link to react native roadmap 2023-07-08 16:38:04 +01:00
Arnav K
7bfd3934f8 🔗 fix: broken link in roadmap cpp (#4181) 2023-07-08 18:04:55 +06:00
Gabriel Coelho da Cunha
32dac79565 [Node.js Developer] Update 102-history-of-nodejs.md (#4179)
Deleted broken link of official documentation and added some suggestions of links that can fill in.
2023-07-08 01:01:06 +06:00
roadmap bot
ceb51a18df chore: add resource under aspnet-core:basics-of-csharp 2023-07-06 17:31:00 +01:00
Dimun
c21f217425 Update typo in introduction-to-llms.md (#4159) 2023-07-06 16:10:21 +01:00
Kamran Ahmed
9299326dc2 Field label for the issue template 2023-07-06 16:06:45 +01:00
Kamran Ahmed
fbe597706a Field label for the issue template 2023-07-06 16:03:49 +01:00
Kamran Ahmed
c7b6257c74 Add new template 2023-07-06 16:03:21 +01:00
Kamran Ahmed
dbe6f8589d Fix duplicate title in the issue template 2023-07-06 16:01:32 +01:00
Kamran Ahmed
9139c8eaf8 Fix broken URL 2023-07-06 15:54:00 +01:00
Kamran Ahmed
05451a0f07 Fix typo 2023-07-06 15:51:48 +01:00
Levon
36d4d8e449 Fix Computer Science roadmap MFU cache description (#4174)
Issue ##4172
2023-07-06 15:50:36 +01:00
Kamran Ahmed
fa8551dd31 Rearrange issues 2023-07-06 15:49:29 +01:00
Kamran Ahmed
7cbf8eb72a Add new issue templates 2023-07-06 15:43:55 +01:00
Kamran Ahmed
e739662d49 Add suggest changes button 2023-07-06 15:39:31 +01:00
Kamran Ahmed
e26fa35470 Add roadmap contribution issue template 2023-07-06 15:33:54 +01:00
Kamran Ahmed
37e92fd084 Add roadmap contribution issue template 2023-07-06 15:31:05 +01:00
Kamran Ahmed
0aef3efda9 Add bug report issue template 2023-07-06 15:18:51 +01:00
Kamran Ahmed
7187da853b Add issue template config 2023-07-06 15:10:31 +01:00
roadmap bot
b81dba9f8b chore: add resource under cyber-security:operating-systems:learn-for-each:understand-permissions 2023-07-06 08:40:32 +01:00
roadmap bot
bf0fd62bff chore: add resource under cyber-security:security-skills-and-knowledge:common-distros-for-hacking:kali-linux 2023-07-06 08:39:16 +01:00
roadmap bot
67e6043cbc chore: add resource under cyber-security:security-skills-and-knowledge:uderstand-frameworks:attck 2023-07-06 08:37:48 +01:00
roadmap bot
9d169219ce chore: add resource under cyber-security:networking-knowledge:understand-the-terminology:vm 2023-07-06 08:36:48 +01:00
roadmap bot
8eb6a0f857 chore: add resource under cyber-security:networking-knowledge:understand-the-terminology:arp 2023-07-06 08:36:36 +01:00
roadmap bot
9c2d3bd2d8 chore: add resource under cyber-security:extras:certifications:beginner-certifications:ccna 2023-07-06 08:36:16 +01:00
roadmap bot
d6de73d7d4 chore: add resource under ux-design:human-decision-making:ux-buzzwords:nudge-theory 2023-07-06 08:36:03 +01:00
roadmap bot
8899654937 chore: add resource under cyber-security:networking-knowledge:understand-the-terminology:dmz 2023-07-06 08:35:52 +01:00
roadmap bot
d64cb4116a chore: add resource under cyber-security:networking-knowledge:understand-the-terminology:vlan 2023-07-06 08:35:34 +01:00
roadmap bot
f428849daa chore: add resource under spring-boot:spring-core 2023-07-05 22:23:51 +01:00
roadmap bot
83143f4438 chore: add resource under postgresql-dba:postgresql-infrastructure-skills:kubernetes-deployment 2023-07-05 22:23:35 +01:00
roadmap bot
8adc6cb7b4 chore: add resource under postgresql-dba:installation-and-setup:deployment-in-cloud 2023-07-05 22:23:27 +01:00
roadmap bot
d12eccb6aa chore: add resource under cyber-security:security-skills-and-knowledge:tools-for-unintended-purposes:lolbas 2023-07-05 22:18:17 +01:00
roadmap bot
93a1dedd8f chore: add resource under nodejs:nodejs-logging 2023-07-05 22:18:06 +01:00
roadmap bot
027a4a947a chore: add resource under ux-design:human-decision-making 2023-07-05 22:17:55 +01:00
roadmap bot
67fd8d3d47 chore: add resource under computer-science:design-patterns 2023-07-05 22:17:39 +01:00
roadmap bot
e42532ad7c chore: add resource under frontend:web-security-knowledge:content-security-policy 2023-07-05 22:16:52 +01:00
roadmap bot
944a35a905 chore: add resource under cyber-security:extras:ctfs:hack-the-box 2023-07-05 22:16:04 +01:00
roadmap bot
9f620866cb chore: add resource under react:cli-tools:create-react-app 2023-07-05 22:15:27 +01:00
roadmap bot
4d74e9c47c chore: add resource under devops:operating-systems:linux:ubuntu 2023-07-05 22:11:43 +01:00
roadmap bot
f1a37deab2 chore: add resource under cpp:basic-operations:loops 2023-07-05 22:10:16 +01:00
roadmap bot
f36dd4b964 chore: add resource under cyber-security:security-skills-and-knowledge:cia-triad 2023-07-05 22:10:00 +01:00
Kamran Ahmed
80c842412a Add docker course link 2023-07-05 22:09:35 +01:00
roadmap bot
219ef68001 chore: add resource under spring-boot:spring-security 2023-07-05 22:08:19 +01:00
roadmap bot
ab8a551a96 chore: add resource under java:java-advanced-topics:memory-management 2023-07-05 22:08:12 +01:00
roadmap bot
467382879d chore: add resource under python:python-frameworks:fastapi 2023-07-05 22:07:57 +01:00
roadmap bot
258f6cd0f0 chore: add resource under postgresql-dba:rdbms-concepts:object-model:tables 2023-07-05 22:07:46 +01:00
roadmap bot
2c3bf1ebbc chore: add resource under postgresql-dba:rdbms-concepts:object-model:data-types 2023-07-05 22:07:37 +01:00
roadmap bot
1113b698be chore: add resource under postgresql-dba:rdbms-concepts:object-model:queries 2023-07-05 22:07:27 +01:00
roadmap bot
eefcc6866b chore: add resource under cyber-security:networking-knowledge:osi-model 2023-07-05 22:07:17 +01:00
roadmap bot
34185ac8fb chore: add resource under devops:serverless 2023-07-05 22:06:24 +01:00
roadmap bot
c1e85ce422 chore: add resource under angular:zones 2023-07-05 22:05:54 +01:00
Iwin Issac
6ed270112d Fixed Broken Link in 100-html.md (#4147) 2023-07-04 18:22:09 +01:00
Fabrício Vilela
df4c457dd4 fix: link pointing from javascript to devops (#4154)
* fix: link pointing from javascript to devops

* fix: return to one line json
2023-07-04 18:16:36 +01:00
Arik Chakma
8a5bc21206 Add account deletion functionality (#4153)
* chore: delete account

* Add account deletion functionality

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-07-04 18:14:56 +01:00
Kamran Ahmed
2d3a89bd56 Add related roadmaps 2023-07-03 18:04:51 +01:00
Kamran Ahmed
d39dad7275 Remove affiliates from devops roadmap 2023-07-03 17:04:12 +01:00
Ankur Jain
37107c495f fix(typo): link updated in 100-servlet.md (#4151)
[FIXED] Last link typos in the Servlet section of the Spring boot developer roadmap titled "What is Dispatcher Servlet in Spring?" 100-servlet.md
2023-07-03 10:34:24 +01:00
roadmap bot
2a910ddde4 chore: add resource under java:java-advanced-topics:streams 2023-07-02 23:38:40 +01:00
roadmap bot
11d7e7d431 chore: add resource under devops:artifcats:artifactory 2023-07-02 23:38:32 +01:00
roadmap bot
991de00891 chore: add resource under system-design:asynchronism:message-queues 2023-07-02 23:38:23 +01:00
roadmap bot
7747582e70 chore: add resource under prompt-engineering:prompt-hacking 2023-07-02 23:38:00 +01:00
roadmap bot
28550ec84c chore: add resource under python:data-structures-and-algorithms 2023-07-02 23:37:31 +01:00
roadmap bot
8246b48f59 chore: add resource under typescript:typescript 2023-07-02 23:37:04 +01:00
roadmap bot
455a70c64c chore: add resource under aspnet-core:basics-of-csharp:csharp 2023-07-02 23:36:13 +01:00
roadmap bot
f0f797a996 chore: add resource under design-system:design-language:guidelines:user-onboarding 2023-07-02 23:36:00 +01:00
roadmap bot
037763770d chore: add resource under cyber-security:networking-knowledge:ip-terminology:subnet-mask 2023-07-02 23:35:41 +01:00
roadmap bot
8d4299c899 chore: add resource under react:components:composition-vs-inheritance 2023-07-02 23:35:31 +01:00
roadmap bot
534ed126d4 chore: add resource under vue:ecosystem:mobile-apps 2023-07-02 23:35:19 +01:00
Kamran Ahmed
0fa6ecd3ce Remove youtube alert 2023-06-30 19:23:58 +01:00
Kamran Ahmed
7dfb630cb5 Update devops roadmap link 2023-06-30 19:15:33 +01:00
roadmap bot
13e1aacd3b chore: add resource under frontend:html:seo-basics 2023-06-30 19:10:24 +01:00
roadmap bot
9ad5143588 chore: add resource under java:java-advanced-topics:generics 2023-06-30 19:01:13 +01:00
roadmap bot
9e867d5f4e chore: add resource under javascript:javascript-control-flow:exception-handling:throw-statement 2023-06-30 19:00:32 +01:00
roadmap bot
f3b186d525 chore: add resource under system-design:latency-vs-throughput 2023-06-30 19:00:23 +01:00
roadmap bot
5f9a50804b chore: add resource under aspnet-core:basics-of-csharp 2023-06-30 18:57:45 +01:00
roadmap bot
486603aff7 chore: add resource under devops:live-in-terminal:process-monitoring 2023-06-30 18:57:37 +01:00
roadmap bot
feec4b7576 chore: add resource under docker:introduction:need-for-containers 2023-06-30 18:57:26 +01:00
roadmap bot
f64f7b973e chore: add resource under prompt-engineering:prompt-hacking:offensive-measures 2023-06-30 18:57:12 +01:00
roadmap bot
31f941e262 chore: add resource under prompt-engineering:prompt-hacking:defensive-measures 2023-06-30 18:57:00 +01:00
roadmap bot
09d312ee46 chore: add resource under prompt-engineering:prompt-hacking:prompt-injection 2023-06-30 18:56:53 +01:00
roadmap bot
a92e8f1b1a chore: add resource under prompt-engineering:prompt-hacking:prompt-leaking 2023-06-30 18:56:46 +01:00
roadmap bot
bca66f7c0b chore: add resource under prompt-engineering:prompt-hacking:jailbreaking 2023-06-30 18:56:38 +01:00
roadmap bot
b743a31610 chore: add resource under react:cli-tools:vite 2023-06-30 18:56:16 +01:00
roadmap bot
b1dc116cae chore: add resource under devops:cloud-providers:aws 2023-06-30 18:55:54 +01:00
roadmap bot
fae57224a8 chore: add resource under javascript:javascript-variables:hoisting 2023-06-30 18:55:35 +01:00
roadmap bot
c8ffea31d9 chore: add resource under devops:language:rust 2023-06-30 18:55:26 +01:00
roadmap bot
fc3b2a4015 chore: add resource under cyber-security:networking-knowledge:auth-methodologies:kerberos 2023-06-30 18:54:53 +01:00
roadmap bot
f70272763f chore: add resource under design-system:design-language:logo:different-file-formats 2023-06-30 11:10:13 +01:00
roadmap bot
a15c2a3ca7 chore: add resource under flutter:deployment:appstore 2023-06-30 11:10:01 +01:00
roadmap bot
550555c0c5 chore: add resource under design-system:design-language:logo:small-use-guidance 2023-06-30 11:09:40 +01:00
roadmap bot
6e201a8c29 chore: add resource under cyber-security:operating-systems:linux 2023-06-30 11:09:21 +01:00
roadmap bot
dd139170d1 chore: add resource under devops:serverless:aws-lambda 2023-06-30 11:09:03 +01:00
roadmap bot
66412327fa chore: add resource under python:data-structures-and-algorithms:arrays-linked-lists 2023-06-30 11:08:37 +01:00
roadmap bot
7736271ba0 chore: add resource under flutter:dart-basics:control-flow-statements 2023-06-30 11:07:45 +01:00
roadmap bot
4236c8495a chore: add resource under cyber-security:extras:certifications:beginner-certifications:comptia-linuxplus 2023-06-30 11:06:33 +01:00
roadmap bot
6c930716fc chore: add resource under ux-design:human-decision-making:ux-buzzwords:nudge-theory 2023-06-30 11:06:21 +01:00
roadmap bot
522b00612a chore: add resource under cyber-security:security-skills-and-knowledge:other-attacks:buffer-overflow 2023-06-30 11:02:56 +01:00
roadmap bot
e36ff7bdd6 chore: add resource under frontend:build-tools:module-bundlers:vite 2023-06-30 11:02:38 +01:00
roadmap bot
d168731cbd chore: add resource under python:python-advanced-topics:iterators 2023-06-30 11:02:15 +01:00
roadmap bot
715daf499f chore: add resource under cyber-security:basic-it-skills:connection-types:wifi 2023-06-30 11:01:59 +01:00
roadmap bot
6f5449e4b9 chore: add resource under cyber-security:basic-it-skills:connection-types:nfc 2023-06-30 11:01:42 +01:00
roadmap bot
7f3690d5b8 chore: add resource under cyber-security:basic-it-skills:connection-types 2023-06-30 11:01:24 +01:00
roadmap bot
1046dc9171 chore: add resource under cyber-security:basic-it-skills:computer-hardware-components 2023-06-30 11:01:11 +01:00
Kamran Ahmed
28f672d989 Update distance of mark favorite 2023-06-27 20:46:36 +01:00
Kamran Ahmed
3f5ddfa346 Add react native content 2023-06-27 20:20:17 +01:00
Kamran Ahmed
f1f4e99dab Add directories for react native roadmap 2023-06-27 20:20:17 +01:00
Kamran Ahmed
67f3917a8d Add react native roadmap 2023-06-27 20:20:17 +01:00
Tabish Naqvi
02988fac2c Issue #4110 Broken Link Fix (#4129) 2023-06-27 19:21:01 +01:00
Ritik Ranjan
d21deb0725 Database spelling mistake (#4115) 2023-06-24 16:49:12 +06:00
Anthony Da Mota
2a5d316e58 Fixed typo in 102-documentation.md (#4108) 2023-06-23 19:11:26 +06:00
Kamran Ahmed
557a01b4d0 Fix typo in docker file 2023-06-22 20:44:37 +01:00
Kamran Ahmed
680dbee6eb Clear favorite on logout 2023-06-22 20:44:37 +01:00
roadmap bot
b525c5efb4 chore: add resource under react:components:functional-components 2023-06-22 20:41:10 +01:00
roadmap bot
347141f93b chore: add resource under cyber-security:operating-systems:windows 2023-06-22 20:40:50 +01:00
Snahal Kumar
9a6e8b1635 C++ Lambdas link (#4084)
The video link is no longer available on youtube. It is updated with another link which I have personally reviewed and has good-quality content of 18 minutes in English has high viewers and is one of the most popular C++ channels on youtube. Also, a free e-book of lambda is provided by the content creator.
2023-06-22 20:38:00 +01:00
Shawn Nectar
8a42d0346b [Issue] #4062 Wrong link assigned (#4077) 2023-06-22 20:37:17 +01:00
Arnav K
0e0a3f17ae Update 101-c.md (#4106) 2023-06-22 20:36:52 +01:00
Shawn Nectar
9298f76a68 [Issue] Software Architect - Duplicated python in related roadmaps (#4078) 2023-06-22 20:14:53 +01:00
Renato C. Francisco
e85ff79dbe remove duplicated top explanation (#4104) 2023-06-22 20:14:35 +01:00
Arnav K
7a19b7887a Fix router link (#4105) 2023-06-22 20:14:05 +01:00
roadmap bot
08e7efa637 chore: add resource under frontend:html:writing-semantic-html 2023-06-22 16:14:51 +01:00
roadmap bot
cc348c0c96 chore: add resource under flutter:state-management:riverpod 2023-06-22 16:14:24 +01:00
roadmap bot
5a059c151f chore: add resource under cyber-security:extras:ctfs:sans-holiday-hack-challenge 2023-06-22 16:14:10 +01:00
roadmap bot
4063b71345 chore: add resource under backend:more-about-databases:orms 2023-06-22 16:13:58 +01:00
roadmap bot
129ef9ccd8 chore: add resource under backend:internet 2023-06-22 16:13:42 +01:00
roadmap bot
d60e4fcfa4 chore: add resource under full-stack:git 2023-06-22 16:13:26 +01:00
roadmap bot
310c6d4c55 chore: add resource under cpp:basic-operations:bitwise 2023-06-22 16:13:11 +01:00
roadmap bot
fffccbe5b5 chore: add resource under cpp:introduction:what-is-cpp 2023-06-22 16:12:50 +01:00
roadmap bot
9685f1e952 chore: add resource under prompt-engineering:image-prompting:style-modifiers 2023-06-22 16:12:16 +01:00
roadmap bot
ef53c2dd5f chore: add resource under frontend:desktop-applications:electron 2023-06-22 16:11:57 +01:00
roadmap bot
7e0f7a32af chore: add resource under software-design-architecture:clean-code-principles:code-by-actor 2023-06-22 16:11:46 +01:00
roadmap bot
cdea68e754 chore: add resource under devops:operating-systems:linux:rhel 2023-06-22 16:11:20 +01:00
roadmap bot
90069e4ef4 chore: add resource under devops:operating-systems:linux:ubuntu 2023-06-22 16:10:50 +01:00
roadmap bot
8dbaa60b58 chore: add resource under golang:go-basics 2023-06-22 16:08:20 +01:00
roadmap bot
19b38dec4c chore: add resource under full-stack:nodejs 2023-06-22 15:58:53 +01:00
Arik Chakma
9c246984d1 Mark favorite in the Roadmap's page (#4098)
* chore: favorite in roadmap header

* chore: best practices header

* chore: mark favorite

* fix: bookmark position

* UI changes and fix

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-06-22 02:57:32 +01:00
Aaryan Dewan
ff0e10c16c Correct grammar (#4095)
Changed 'al' to 'all'
2023-06-21 20:40:56 +06:00
roadmap bot
ec165d4a78 chore: add resource under devops:networking-protocols 2023-06-20 22:03:44 +01:00
Arik Chakma
afe718ee09 Allow marking roadmaps and best practices as favorites (#4087)
* chore: favorite icon

* fix: hero progress mark favorit

* chore: mark favorite

* fix: mouse overflow

* fix: popup redirect

* Update favorites on homepage

* Refactor favorite logic

* Change icon location

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-06-20 21:50:18 +01:00
Ritik Ranjan
4aca01a98d Fix spelling mistake (#4088) 2023-06-20 18:24:48 +01:00
Kamran Ahmed
140282f1ff Update devops roadmap link 2023-06-20 17:31:31 +01:00
roadmap bot
4d38d19e4f chore: add resource under aspnet-core:basics-of-aspnet-core:filters-and-attributes 2023-06-20 15:16:05 +01:00
roadmap bot
5e39417a64 chore: add resource under cyber-security:security-skills-and-knowledge:common-exploit-frameworks 2023-06-20 15:15:57 +01:00
roadmap bot
03ec7ebcd9 chore: add resource under javascript:javascript-variables:scopes 2023-06-20 15:15:44 +01:00
roadmap bot
fbb6def555 chore: add resource under computer-science:pick-a-language:c-plus-plus 2023-06-20 15:15:24 +01:00
roadmap bot
ae9e30eb73 chore: add resource under mongodb:mongodb-basics:sql-vs-nosql 2023-06-20 15:15:03 +01:00
roadmap bot
9e89c6946b chore: add resource under ux-design:human-decision-making 2023-06-20 15:14:48 +01:00
Arik Chakma
6ff83d0797 Merge pull request #3766 from jensrott/fix-typo-playwright
Fixed typo in the word tutorial
2023-06-20 00:53:01 +06:00
Arik Chakma
5ff131ae29 Merge pull request #3873 from the-land-mine/master
fix: Correct syntax error in Promise initialization example by adding space
2023-06-20 00:51:52 +06:00
Arik Chakma
e80f88ef2c Merge pull request #4049 from arzkar/issue4044_fix
fix: typo: mor -> more
2023-06-20 00:49:50 +06:00
Arik Chakma
cff01c151b Merge pull request #4080 from JustLolo/master
The external link is broken
2023-06-20 00:48:48 +06:00
Arik Chakma
6ca85a41a2 Merge pull request #4081 from johan456789/master
fix URL link
2023-06-20 00:46:32 +06:00
JustLolo
1630b493b1 External link is broken, fixed 2023-06-19 06:41:26 -05:00
Tsung-Han Yu
518ece3cab fix URL link 2023-06-19 10:34:37 +08:00
JustLolo
aba2fd1d35 External broken link, Youtube is showing:
`This video isn't available anymore`
2023-06-18 18:11:38 -05:00
Arik Chakma
fcd68568c2 Merge pull request #4076 from ShawnNectar/patch-1
[Issue] #4075 Wrong link assigned
2023-06-18 22:11:26 +06:00
Shawn Nectar
1b5e9ffe0d [Issue] #4075 Wrong link assigned 2023-06-18 12:58:33 -03:00
Kamran Ahmed
b3c3e44ba2 Update shortcut for marking as skipped 2023-06-17 23:13:59 +01:00
Kamran Ahmed
67b49d3f87 Remove new badges from old roadmaps 2023-06-17 16:17:42 +01:00
roadmap bot
0d3e1d31bb chore: add resource under aspnet-core:orm:entity-framework-core:change-tracker-api 2023-06-17 15:23:52 +01:00
roadmap bot
28a27a1c65 chore: add resource under computer-science:pick-a-language:c 2023-06-17 15:23:36 +01:00
roadmap bot
8c3ea21ef1 chore: add resource under cpp:introduction 2023-06-17 15:22:41 +01:00
roadmap bot
417596db36 chore: add resource under frontend:progressive-web-apps:notifications 2023-06-17 15:22:30 +01:00
roadmap bot
28240162b3 chore: add resource under frontend:build-tools:module-bundlers:esbuild 2023-06-17 15:22:11 +01:00
roadmap bot
6dca357782 chore: add resource under blockchain:blockchain-general-knowledge:blockchain-forking 2023-06-17 15:21:57 +01:00
roadmap bot
d1fe06a4e9 chore: add resource under flutter:widgets:responsive-widgets 2023-06-17 15:20:28 +01:00
roadmap bot
97cba5681b chore: add resource under full-stack:html 2023-06-17 15:20:15 +01:00
roadmap bot
715d2ba62b chore: add resource under golang:go-advanced:working-with-json 2023-06-17 15:19:54 +01:00
Kamran Ahmed
32673c21fb Add shortcuts for progress tracking 2023-06-17 15:19:24 +01:00
roadmap bot
f0c47705cb chore: add resource under nodejs:nodejs-command-line-apps:command-line-args 2023-06-17 15:17:18 +01:00
roadmap bot
612b91e05f chore: add resource under full-stack:nodejs 2023-06-17 15:17:08 +01:00
roadmap bot
b4cce42844 chore: add resource under devops:serverless:azure-functions 2023-06-17 15:16:41 +01:00
roadmap bot
2c2d57ecab chore: add resource under cpp:functions 2023-06-17 15:16:36 +01:00
roadmap bot
d05374ca68 chore: add resource under ux-design:human-decision-making:ux-buzzwords:nudge-theory 2023-06-17 15:16:14 +01:00
roadmap bot
b5c02a9aff chore: add resource under cyber-security:basic-it-skills:popular-suites:icloud 2023-06-17 15:16:04 +01:00
roadmap bot
1e3568a1c4 chore: add resource under cyber-security:networking-knowledge:understand-the-terminology:dns 2023-06-17 15:15:44 +01:00
Arik Chakma
bdeebbc9cc chore: linkedin login functionality (#4072) 2023-06-17 12:31:33 +01:00
Kamran Ahmed
510e6fd273 Update youtube banner 2023-06-17 11:49:15 +01:00
Kamran Ahmed
2ca98bbb10 Show resource progress on best practices 2023-06-17 11:47:25 +01:00
Kamran Ahmed
49cff0c22c Add progress loading on roadmap pages 2023-06-17 05:43:50 +01:00
Kamran Ahmed
943bf41dc5 Fix duplicate nodes in frontend roadmap 2023-06-17 04:30:58 +01:00
Umair Khan
6c9ba75906 Fix KodeKloud spelling (#4066)
* Update devops.json 

kodekloud spelling correction

* Update src/data/roadmaps/devops/devops.json

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-06-15 22:17:53 +01:00
Kamran Ahmed
70976ee42a Add roadcard as protected route 2023-06-15 14:51:52 +01:00
roadmap bot
5848698abf chore: add resource under python:python-advanced-topics:lambdas 2023-06-15 02:27:28 +01:00
roadmap bot
29dd1eb21f chore: add resource under python:python-advanced-topics:decorators 2023-06-15 02:27:17 +01:00
roadmap bot
ebe6d3c6e4 chore: add resource under system-design:latency-vs-throughput 2023-06-15 02:27:06 +01:00
roadmap bot
425bfea265 chore: add resource under system-design:performance-vs-scalability 2023-06-15 02:26:57 +01:00
roadmap bot
c58efe8d00 chore: add resource under python:python-advanced-topics:oop:classes 2023-06-15 02:26:39 +01:00
Kamran Ahmed
955d04e532 UI changes on road cards 2023-06-14 20:58:15 +01:00
Kamran Ahmed
0031a9c6ba Remove preact-compat 2023-06-14 20:42:32 +01:00
Kamran Ahmed
8fb778337d Add support for roadcards 2023-06-14 20:42:07 +01:00
Kamran Ahmed
a48d39a863 Update animation of progress switcher 2023-06-14 15:02:53 +01:00
Kamran Ahmed
36b2a8f2d7 Progress container update 2023-06-14 14:46:10 +01:00
Kamran Ahmed
00e9d44ba9 Remove client:only from favorite roadmaps 2023-06-14 13:54:54 +01:00
Kamran Ahmed
62b068a94a Fix jitter on homepage 2023-06-14 13:51:29 +01:00
Kamran Ahmed
af926002e9 feat: show user's progress on homepage (#4058)
* Add custom client:authenticated directive

* Update 100-installing-a-local-cluster.md

fixed typo for ubuntu in 100-installing-a-local-cluster.md

* Animate progress on the homescreen

* Show progress on homepage

* Update progress list UI

* Remove sponsor call from non-required pages

* Resolve merge conflicts

* Change height of hero container

---------

Co-authored-by: kanhaya kumar yadav <kanhaya.workspace@gmail.com>
2023-06-14 13:12:52 +01:00
roadmap bot
0612f9c44f chore: add resource under docker:introduction:what-are-containers 2023-06-14 03:47:19 +01:00
roadmap bot
fbf545c2ed chore: add resource under cyber-security:security-skills-and-knowledge:common-distros-for-hacking:parrot-os 2023-06-14 03:47:04 +01:00
roadmap bot
c7ef97cb4f chore: add resource under react:rendering:refs 2023-06-14 03:46:43 +01:00
roadmap bot
564f48540e chore: add resource under react:rendering:render-props 2023-06-14 03:46:29 +01:00
roadmap bot
52e729d212 chore: add resource under prompt-engineering:prompting-introduction 2023-06-14 03:45:32 +01:00
roadmap bot
bdfa7606dd chore: add resource under devops:live-in-terminal:scripting:powershell 2023-06-14 03:45:21 +01:00
roadmap bot
056e0e8e3a chore: add resource under react:rendering:lists-and-keys 2023-06-14 03:45:11 +01:00
roadmap bot
879ba258b2 chore: add resource under cyber-security:basic-it-skills:connection-types:wifi 2023-06-14 03:44:40 +01:00
Kamran Ahmed
3d62d2689f Animate progress on the homescreen 2023-06-14 02:09:09 +01:00
Arbaaz Laskar
3b7a9ca5cd fix: typo: mor -> more 2023-06-14 00:06:37 +05:30
Arik Chakma
ac892d2868 Merge pull request #4047 from kanhayaKy/patch-1
fix: ubuntu type
2023-06-13 17:57:19 +06:00
roadmap bot
19bde7bb2f chore: add resource under cyber-security:security-skills-and-knowledge:forensics 2023-06-13 11:38:12 +01:00
roadmap bot
419b1872b8 chore: add resource under javascript:javascript-asynchronous-javascript:callbacks 2023-06-13 11:37:50 +01:00
roadmap bot
bbeb4ee279 chore: add resource under devops:live-in-terminal:scripting:bash-scripting 2023-06-13 11:37:42 +01:00
roadmap bot
f2ca7d9140 chore: add resource under backend:relational-databases:mysql 2023-06-13 11:37:12 +01:00
roadmap bot
70b95c6ad1 chore: add resource under javascript:javascript-asynchronous-javascript:callbacks:callback-hell 2023-06-13 11:36:49 +01:00
roadmap bot
5a3f621093 chore: add resource under javascript:javascript-loops-iterations:break-continue:labeled-statements 2023-06-13 11:36:41 +01:00
roadmap bot
631eb380fc chore: add resource under cpp:pointers-and-references:smart-pointers:weak-ptr 2023-06-13 11:36:31 +01:00
roadmap bot
cb9778ba15 chore: add resource under cyber-security:basic-it-skills:os-independent-troubleshooting 2023-06-13 11:36:20 +01:00
roadmap bot
38106a8199 chore: add resource under typescript:typescript-types:type-assertions:as-type 2023-06-13 11:35:46 +01:00
roadmap bot
226e94857b chore: add resource under aspnet-core:basics-of-csharp:csharp 2023-06-13 11:35:31 +01:00
roadmap bot
f94c701657 chore: add resource under computer-science:pick-a-language:c-plus-plus 2023-06-13 11:35:17 +01:00
roadmap bot
259109cc38 chore: add resource under cyber-security:basic-it-skills 2023-06-13 11:35:04 +01:00
kanhaya kumar yadav
e120df30e3 Update 100-installing-a-local-cluster.md
fixed typo for ubuntu in 100-installing-a-local-cluster.md
2023-06-13 11:46:55 +05:30
Kamran Ahmed
43f351a943 Add progress loading on homepage roadmaps 2023-06-13 03:19:59 +01:00
roadmap bot
502b8e20d5 chore: add resource under computer-science:common-algorithms:graph-algorithms:breadth-first-search 2023-06-11 18:44:23 +01:00
roadmap bot
ff5858f965 chore: add resource under flutter:widgets:inherited-widgets 2023-06-11 18:43:59 +01:00
roadmap bot
8b8ef52d98 chore: add resource under python:python-basics 2023-06-11 18:43:37 +01:00
roadmap bot
7032bc0726 chore: add resource under backend:repo-hosting-services:github 2023-06-11 18:43:29 +01:00
roadmap bot
ba65dec596 chore: add resource under cpp:libraries:poco 2023-06-11 18:42:48 +01:00
roadmap bot
78cf88fbd9 chore: add resource under flutter:design-principles:design-patterns 2023-06-11 02:10:16 +01:00
roadmap bot
93e16d899a chore: add resource under devops:artifcats:nexus 2023-06-11 02:09:49 +01:00
roadmap bot
14060bda94 chore: add resource under javascript:javascript-control-flow:exception-handling:throw-statement 2023-06-11 02:08:21 +01:00
Kamran Ahmed
45b729d708 Update the schema updated date 2023-06-10 20:28:13 +01:00
roadmap bot
9023ea6298 chore: add resource under angular:typescript-basics:union-types 2023-06-10 14:06:00 +01:00
Kamran Ahmed
d29176cf98 Add links to beginner versions 2023-06-10 11:40:24 +01:00
Kamran Ahmed
55989d8480 Add updated devops roadmap pdf 2023-06-10 04:03:38 +01:00
Kamran Ahmed
9c936974c7 Add devops beginner roadmap 2023-06-10 04:02:51 +01:00
Kamran Ahmed
311b4683d0 Rewrite devops roadmap 2023-06-10 04:02:48 +01:00
roadmap bot
bf61697154 chore: add resource under react:hooks:common-hooks 2023-06-09 21:01:31 +01:00
roadmap bot
52818f1e34 chore: add resource under blockchain:blockchain-basics 2023-06-09 21:01:17 +01:00
roadmap bot
174ea05a92 chore: add resource under devops:infrastructure-as-code:kubernetes 2023-06-09 20:59:40 +01:00
roadmap bot
dcb4e06fea chore: add resource under cyber-security:security-skills-and-knowledge:blue-team-read-team-purple-team 2023-06-09 01:53:31 +01:00
roadmap bot
62eb6a4a01 chore: add resource under postgresql-dba:introduction:what-are-relational-databases 2023-06-09 01:53:04 +01:00
roadmap bot
f643f3bd9a chore: add resource under kubernetes:running-applications:deployments 2023-06-09 01:52:10 +01:00
roadmap bot
972370e0e6 chore: add resource under angular:typescript-basics:type-guard 2023-06-09 01:52:01 +01:00
roadmap bot
a6feb72339 chore: add resource under cyber-security:basic-it-skills:connection-types:nfc 2023-06-09 01:51:47 +01:00
roadmap bot
c751706631 chore: add resource under cpp:functions:lambda 2023-06-09 01:51:32 +01:00
roadmap bot
8900324234 chore: add resource under frontend:css:responsive-design-and-media-queries 2023-06-09 01:51:04 +01:00
roadmap bot
f1b880d898 chore: add resource under java:java-advanced-topics:memory-management 2023-06-09 01:50:50 +01:00
roadmap bot
9a285d7470 chore: add resource under cpp:setting-up:code-editors 2023-06-09 01:50:36 +01:00
roadmap bot
15259560e0 chore: add resource under javascript:javascript-control-flow:exception-handling 2023-06-09 01:50:19 +01:00
roadmap bot
d8afa166aa chore: add resource under python:python-basics:variables-and-datatypes 2023-06-08 14:34:57 +01:00
roadmap bot
d39791257e chore: add resource under cpp:introduction:what-is-cpp 2023-06-07 14:48:42 +01:00
Kamran Ahmed
06b7005782 chore: add resource under cyber-security:security-skills-and-knowledge:attack-types:phishing-vishing-whaling-smishing 2023-06-07 11:24:23 +01:00
Kamran Ahmed
bc6c933440 chore: add resource under cyber-security:security-skills-and-knowledge:cryptography 2023-06-07 11:24:05 +01:00
Kamran Ahmed
b965a89db3 chore: add resource under java:java-advanced-topics:garbage-collection 2023-06-07 11:23:32 +01:00
Kamran Ahmed
9b82e327e2 chore: add resource under backend:learn-a-language:java 2023-06-07 11:23:01 +01:00
Kamran Ahmed
5808125d92 chore: add resource under computer-science:pick-a-language:c-plus-plus 2023-06-07 11:22:38 +01:00
Kamran Ahmed
f49fe258aa chore: add resource under cyber-security:basic-it-skills:connection-types:nfc 2023-06-07 11:22:05 +01:00
Kamran Ahmed
08df9e8c33 chore: add resource under cyber-security:extras:certifications:beginner-certifications:comptia-aplus 2023-06-07 11:21:46 +01:00
Kamran Ahmed
56e388edd8 chore: add resource under backend:apis:authentication:openid 2023-06-07 11:21:08 +01:00
Kamran Ahmed
ded75c7af1 chore: add resource under golang:go-basics:structs 2023-06-07 11:20:44 +01:00
Edwin Manual
b6c8260faf fix: Correct syntax error in Promise initialization example by adding space 2023-04-29 07:27:34 +05:30
Jens Rottiers
03f69c02c1 Fixed typo in the word tutorial 2023-04-06 10:08:45 +02:00
773 changed files with 58276 additions and 17242 deletions

View File

@@ -0,0 +1,25 @@
name: "✍️ Suggest Changes"
description: Help us improve the roadmaps by suggesting changes
labels: [suggestion]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help us improve the roadmaps with your suggestions.
- type: input
id: url
attributes:
label: Roadmap URL
description: Please provide the URL of the roadmap you are suggesting changes to.
placeholder: https://roadmap.sh
validations:
required: true
- type: textarea
id: roadmap-suggestions
attributes:
label: Suggestions
description: What changes would you like to suggest?
placeholder: Enter your suggestions here.
validations:
required: true

View File

@@ -0,0 +1,42 @@
name: "🐛 Bug Report"
description: Report an issue or possible bug
labels: [bug]
assignees: []
body:
- type: input
id: url
attributes:
label: What is the URL where the issue is happening
placeholder: https://roadmap.sh
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: What browsers are you seeing the problem on?
multiple: true
options:
- Firefox
- Chrome
- Safari
- Microsoft Edge
- Other
- type: textarea
id: bug-description
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Output from browser console (if any)
description: Please copy and paste any relevant log output.
- type: checkboxes
id: will-pr
attributes:
label: Participation
options:
- label: I am willing to submit a pull request for this issue.
required: false

View File

@@ -0,0 +1,12 @@
name: "✨ Feature Suggestion"
description: Is there a feature you'd like to see on Roadmap.sh? Let us know!
labels: [feature request]
assignees: []
body:
- type: textarea
id: feature-description
attributes:
label: Feature Description
description: Please provide a detailed description of the feature you are suggesting and how it would help you/others.
validations:
required: true

View File

@@ -0,0 +1,37 @@
name: "🙏 Submit a Roadmap"
description: Help us launch a new roadmap with your expertise.
labels: [roadmap contribution]
assignees: []
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to submit a roadmap! Please fill out the information below and we'll get back to you as soon as we can.
- type: input
id: roadmap-title
attributes:
label: What is the title of the roadmap you are submitting?
placeholder: e.g. Roadmap to learn Data Science
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: Is this roadmap prepared by you or someone else?
options:
- I prepared this roadmap
- I found this roadmap online (please provide a link below)
- type: textarea
id: roadmap-description
attributes:
label: Roadmap Items
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
placeholder: |
- Item 1
- Subitem 1
- Subitem 2
- Item 2
- Subitem 1
- Subitem 2
validations:
required: true

View File

@@ -0,0 +1,12 @@
name: "🤷‍♂️ Something else"
description: If none of the above templates fit your needs, please use this template to submit your issue.
labels: []
assignees: []
body:
- type: textarea
id: issue-description
attributes:
label: Detailed Description
description: Please provide a detailed description of the issue.
validations:
required: true

14
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: Roadmap Request
url: https://discord.gg/cJpEt5Qbwa
about: Please do not open issues with roadmap requests, hop onto the discord server for that.
- name: 📝 Typo or Grammatical Mistake
url: https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data
about: Please submit a pull request instead of reporting it as an issue.
- name: 💬 Chat on Discord
url: https://discord.gg/cJpEt5Qbwa
about: Join the community on our Discord server.
- name: 🤝 Guidance
url: https://discord.gg/cJpEt5Qbwa
about: Join the community in our Discord server.

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.idea
# build output
dist/
.output/

View File

@@ -5,6 +5,7 @@ import tailwind from '@astrojs/tailwind';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config';
import rehypeExternalLinks from 'rehype-external-links';
import { fileURLToPath } from 'node:url';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
// https://astro.build/config
@@ -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,

View File

@@ -16,7 +16,7 @@ For new roadmaps, submit a roadmap by providing [a textual roadmap similar to th
For the existing roadmaps, please follow the details listed for the nature of contribution:
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/public/jsons)
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps)
- **Adding or Removing Nodes** — Please open an issue with your suggestion.
**Note:** Please note that our goal is not to have the biggest list of items. Our goal is to list items or skills most relevant today.

View File

@@ -26,27 +26,28 @@
"@astrojs/tailwind": "^3.1.3",
"@fingerprintjs/fingerprintjs": "^3.4.1",
"@nanostores/preact": "^0.5.0",
"astro": "^2.5.7",
"astro-compress": "^1.1.46",
"astro": "^2.6.6",
"astro-compress": "^1.1.47",
"jose": "^4.14.4",
"js-cookie": "^3.0.5",
"nanostores": "^0.9.1",
"nanostores": "^0.9.2",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.10.12",
"preact": "^10.15.1",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@playwright/test": "^1.34.3",
"@playwright/test": "^1.35.1",
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"csv-parser": "^3.0.0",
"gh-pages": "^5.0.0",
"js-yaml": "^4.1.0",
"markdown-it": "^13.0.1",
"openai": "^3.2.1",
"openai": "^3.3.0",
"prettier": "^2.8.8",
"prettier-plugin-astro": "^0.10.0",
"prettier-plugin-tailwindcss": "^0.3.0"

3284
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/roadmaps/sql.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

View File

@@ -30,9 +30,9 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
Here is the list of available roadmaps with more being actively worked upon.
- [Frontend Roadmap](https://roadmap.sh/frontend)
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
- [Backend Roadmap](https://roadmap.sh/backend)
- [DevOps Roadmap](https://roadmap.sh/devops)
- [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)
- [QA Roadmap](https://roadmap.sh/qa)
@@ -42,6 +42,7 @@ Here is the list of available roadmaps with more being actively worked upon.
- [TypeScript Roadmap](https://roadmap.sh/typescript)
- [C++ Roadmap](https://roadmap.sh/cpp)
- [React Roadmap](https://roadmap.sh/react)
- [React Native Roadmap](https://roadmap.sh/react-native)
- [Vue Roadmap](https://roadmap.sh/vue)
- [Angular Roadmap](https://roadmap.sh/angular)
- [Node.js Roadmap](https://roadmap.sh/nodejs)
@@ -53,7 +54,8 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Java Roadmap](https://roadmap.sh/java)
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
- [Design System Roadmap](https://roadmap.sh/design-system)
- [DBA Roadmap](https://roadmap.sh/postgresql-dba)
- [PostgreSQL Roadmap](https://roadmap.sh/postgresql-dba)
- [SQL Roadmap](https://roadmap.sh/sql)
- [Blockchain Roadmap](https://roadmap.sh/blockchain)
- [ASP.NET Core Roadmap](https://roadmap.sh/aspnet-core)
- [System Design Roadmap](https://roadmap.sh/system-design)
@@ -93,6 +95,12 @@ npm install
npm run dev
```
Note: use the `depth` parameter to reduce the clone size and speed up the clone.
```sh
git clone --depth=1 https://github.com/kamranahmedse/developer-roadmap.git
```
## Contribution
> Have a look at [contribution docs](./contributing.md) for how to update any of the roadmaps

View File

@@ -3,7 +3,6 @@ const path = require('path');
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
const ALL_ROADMAPS_DIR = path.join(__dirname, '../src/data/roadmaps');
const ROADMAP_JSON_DIR = path.join(__dirname, '../public/jsons/roadmaps');
const roadmapId = process.argv[2];
@@ -139,7 +138,11 @@ async function writeFileForGroup(group, topicUrlToPathMapping) {
async function run() {
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
const roadmapJson = require(path.join(ROADMAP_JSON_DIR, `${roadmapId}.json`));
const roadmapJson = require(path.join(
ALL_ROADMAPS_DIR,
`${roadmapId}/${roadmapId}`
));
const groups = roadmapJson?.mockup?.controls?.control?.filter(
(control) =>
control.typeID === '__group__' &&

View File

@@ -84,8 +84,9 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
const roadmap = require(path.join(
__dirname,
`../public/jsons/roadmaps/${roadmapId}`
`../src/data/roadmaps/${roadmapId}/${roadmapId}`
));
const controls = roadmap.mockup.controls.control;
// Prepare the dir tree that we will be creating and also calculate the sort orders

View File

@@ -1,47 +1,62 @@
---
import AstroIcon from './AstroIcon.astro';
const { activePageId, activePageTitle } = Astro.props;
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
export interface Props {
activePageId: string;
activePageTitle: string;
hasDesktopSidebar?: boolean;
}
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
const sidebarLinks = [
{
href: '/account',
title: 'Activity',
id: 'activity',
icon: {
glyph: 'analytics',
classes: 'h-3 w-4',
}
{
href: '/account',
title: 'Activity',
id: 'activity',
isNew: false,
icon: {
glyph: 'analytics',
classes: 'h-3 w-4',
},
{
href: '/account/update-profile',
title: 'Profile',
id: 'profile',
icon: {
glyph: 'user',
classes: 'h-4 w-4',
}
},
{
href: '/account/road-card',
title: 'Card',
id: 'road-card',
isNew: true,
icon: {
glyph: 'badge',
classes: 'h-4 w-4',
},
{
href: '/account/update-password',
title: 'Security',
id: 'change-password',
icon: {
glyph: 'security',
classes: 'h-4 w-4'
}
},
{
href: '/account/update-profile',
title: 'Profile',
id: 'profile',
isNew: false,
icon: {
glyph: 'user',
classes: 'h-4 w-4',
},
},
{
href: '/account/settings',
title: 'Settings',
id: 'settings',
isNew: false,
icon: {
glyph: 'cog',
classes: 'h-4 w-4',
},
},
];
---
<div class='relative mb-5 block border-b p-4 shadow-inner md:hidden'>
<button
class='flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-gray-900 text-sm font-medium'
class='flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-sm font-medium text-gray-900'
id='settings-menu'
>
{activePageTitle}
@@ -51,50 +66,90 @@ 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>-->
{
sidebarLinks.map((sidebarLink) => (
<li>
<a
href={sidebarLink.href}
class={`flex items-center w-full rounded px-3 py-1.5 text-slate-900 hover:bg-slate-200 text-sm ${
activePageId === sidebarLink.id ? 'bg-slate-100' : ''
}`}
>
<AstroIcon icon={sidebarLink.icon.glyph} class={`${sidebarLink.icon.classes} mr-2`} />
sidebarLinks.map((sidebarLink) => {
const isActive = activePageId === sidebarLink.id;
return (
<li>
<a
href={sidebarLink.href}
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
isActive ? 'bg-slate-100' : ''
}`}
>
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.title}
</a>
</li>
))
</a>
</li>
);
})
}
</ul>
</div>
<div class='container flex min-h-screen items-stretch'>
<!-- Start Desktop Sidebar -->
<aside class='hidden shrink-0 w-44 border-r border-slate-200 py-10 md:block'>
<nav>
<ul class='space-y-1'>
{
sidebarLinks.map((sidebarLink) => (
<li>
<a
href={sidebarLink.href}
class={`font-regular flex w-full items-center gap-2 px-2 py-1.5 text-sm border-r-2 ${
activePageId === sidebarLink.id ? 'text-black border-r-black bg-gray-100' : 'text-gray-500 border-r-transparent hover:border-r-gray-300'
}`}
>
<AstroIcon icon={sidebarLink.icon.glyph} class={`${sidebarLink.icon.classes} mr-0`} />
{sidebarLink.title}
</a>
</li>
))
}
</ul>
</nav>
</aside>
{
hasDesktopSidebar && (
<aside class='hidden w-[195px] shrink-0 border-r border-slate-200 py-10 md:block'>
<TeamDropdown client:load />
<nav>
<ul class='space-y-1'>
{sidebarLinks.map((sidebarLink) => {
const isActive = activePageId === sidebarLink.id;
return (
<li>
<a
href={sidebarLink.href}
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
isActive
? 'border-r-black bg-gray-100 text-black'
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
}`}
>
<span class='flex flex-grow items-center'>
<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>
)}
</a>
</li>
);
})}
</ul>
</nav>
</aside>
)
}
<!-- /End Desktop Sidebar -->
<div class='grow px-0 py-0 md:px-10 md:py-10'>
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
<slot />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
type ActivityResponse = {
export type ActivityResponse = {
done: {
today: number;
total: number;

View File

@@ -1,4 +1,4 @@
import CheckIcon from '../../icons/roadmap.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
export function EmptyActivity() {
return (
@@ -6,7 +6,7 @@ export function EmptyActivity() {
<div class="flex flex-col items-center p-7 text-center">
<img
alt="no roadmaps"
src={CheckIcon}
src={RoadmapIcon}
class="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
/>
<h2 class="text-lg sm:text-xl font-bold">No Progress</h2>

View File

@@ -1,6 +1,7 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
@@ -11,10 +12,13 @@ type ResourceProgressType = {
doneCount: number;
learningCount: number;
skippedCount: number;
onCleared: () => void;
onCleared?: () => void;
showClearButton?: boolean;
};
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
@@ -41,17 +45,20 @@ export function ResourceProgress(props: ResourceProgressType) {
);
if (error || !response) {
alert('Error clearing progress. Please try again.');
toast.error('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
console.log(`${resourceType}-${resourceId}-progress`);
setIsClearing(false);
setIsConfirming(false);
onCleared();
if (onCleared) {
onCleared();
}
}
const url =
@@ -100,38 +107,42 @@ export function ResourceProgress(props: ResourceProgressType) {
)}
<span>{totalCount} total</span>
</span>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
{showClearButton && (
<>
{!isConfirming && (
<button
className="text-red-500 hover:text-red-800"
onClick={() => setIsConfirming(true)}
disabled={isClearing}
>
{!isClearing && (
<>
Clear Progress <span>&times;</span>
</>
)}
{isClearing && 'Processing...'}
</button>
)}
{isClearing && 'Processing...'}
</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>
{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>
)}
</>
)}
</p>
</div>

View File

@@ -0,0 +1,174 @@
import { useRef, useState } from 'preact/hooks';
import { useOutsideClick } from '../hooks/use-outside-click';
import { OptionType, SearchSelector } from './SearchSelector';
import type { PageType } from './CommandMenu/CommandMenu';
import { CheckIcon } from './ReactIcons/CheckIcon';
import { httpPut } from '../lib/http';
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
import { Spinner } from './ReactIcons/Spinner';
type AddTeamRoadmapProps = {
teamId: string;
allRoadmaps: PageType[];
availableRoadmaps: PageType[];
onClose: () => void;
onMakeChanges: (roadmapId: string) => void;
setResourceConfigs: (config: TeamResourceConfig) => void;
};
export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
const {
teamId,
onMakeChanges,
onClose,
allRoadmaps,
availableRoadmaps,
setResourceConfigs,
} = props;
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedRoadmap, setSelectedRoadmap] = useState<string>('');
const popupBodyEl = useRef<HTMLDivElement>(null);
async function addTeamResource(roadmapId: string) {
if (!teamId) {
return;
}
setIsLoading(true);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
);
if (error || !response) {
setError(error?.message || 'Error adding roadmap');
return;
}
setResourceConfigs(response);
}
useOutsideClick(popupBodyEl, () => {
onClose();
});
const selectedRoadmapTitle = allRoadmaps.find(
(roadmap) => roadmap.id === selectedRoadmap
)?.title;
return (
<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}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
{isLoading && (
<>
<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>
</>
)}
{!isLoading && !error && selectedRoadmap && (
<div className={'text-center'}>
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
<h3 class="mb-1.5 text-2xl font-medium">
{selectedRoadmapTitle} Added
</h3>
<p className="mb-4 text-sm leading-none text-gray-400">
<button
onClick={() => onMakeChanges(selectedRoadmap)}
className="underline underline-offset-2 hover:text-gray-900"
>
Click here
</button>{' '}
to make changes to the roadmap.
</p>
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Done
</button>
<button
onClick={() => {
setSelectedRoadmap('');
setError('');
setIsLoading(false);
}}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
>
+ Add More
</button>
</div>
</div>
)}
{!isLoading && error && (
<>
<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 class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>
</div>
</>
)}
{!isLoading && !error && !selectedRoadmap && (
<>
<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>
<SearchSelector
options={availableRoadmaps.map((roadmap) => ({
value: roadmap.id,
label: roadmap.title,
}))}
onSelect={(option: OptionType) => {
const roadmapId = option.value;
addTeamResource(roadmapId).finally(() => {
setIsLoading(false);
setSelectedRoadmap(roadmapId);
});
}}
inputClassName="mt-2 mb-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
placeholder={'Search for roadmap'}
/>
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -90,8 +90,13 @@ 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 =
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, window.location.pathname);
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
}
window.location.href = response.loginUrl;

View File

@@ -85,8 +85,13 @@ 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 =
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, window.location.pathname);
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
}
window.location.href = response.loginUrl;

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import LinkedIn from '../../icons/linkedin.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
type LinkedInButtonProps = {};
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
export function LinkedInButton(props: LinkedInButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : LinkedIn;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const provider = urlParams.get('provider');
if (!code || !state || provider !== 'linkedin') {
return;
}
setIsLoading(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
window.location.search
}`
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
let redirectUrl = '/';
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
// If the social redirect is there and less than 30 seconds old
// redirect to the page that user was on before they clicked the github login button
if (linkedInRedirectAt && lastPageBeforeLinkedIn) {
const socialRedirectAtTime = parseInt(linkedInRedirectAt, 10);
const now = Date.now();
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeLinkedIn;
}
}
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
localStorage.removeItem(LINKEDIN_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
}, []);
const handleClick = () => {
setIsLoading(true);
httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
)
.then(({ response, error }) => {
if (!response?.loginUrl) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
// 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 =
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);
}
window.location.href = response.loginUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
};
return (
<>
<button
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}
alt="Google"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with LinkedIn
</button>
{error && (
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
)}
</>
);
}

View File

@@ -4,6 +4,7 @@ import EmailLoginForm from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
import { LinkedInButton } from './LinkedInButton';
---
<Popup id='login-popup' title='' subtitle=''>
@@ -19,6 +20,7 @@ import { GoogleButton } from './GoogleButton';
<div class='mt-7 flex flex-col gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
<LinkedInButton client:load />
</div>
<Divider />

View File

@@ -33,8 +33,17 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
function handleGuest() {
const authenticatedRoutes = [
'/account/update-profile',
'/account/notification',
'/account/update-password',
'/account/settings',
'/account/road-card',
'/account',
'/team',
'/team/progress',
'/team/roadmaps',
'/team/new',
'/team/members',
'/team/settings'
];
showHideAuthElements('hide');

View File

@@ -2,6 +2,8 @@
import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import BestPracticeHint from './BestPracticeHint.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import ProgressHelpPopup from './ProgressHelpPopup.astro';
export interface Props {
title: string;
@@ -15,36 +17,43 @@ const isBestPracticeReady = !isUpcoming;
---
<LoginPopup />
<ProgressHelpPopup />
<div class='border-b'>
<div class='container relative py-5 sm:py-12'>
<div class='mb-3 mt-0 sm:mb-6'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
<div class="border-b">
<div class="container relative py-5 sm:py-12">
<div class="mb-3 mt-0 sm:mb-6">
<h1 class="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl">
{title}
<MarkFavorite
resourceId={bestPracticeId}
resourceType="best-practice"
className="text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0"
client:load
/>
</h1>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
<p class="text-sm text-gray-500 sm:text-lg">{description}</p>
</div>
<div class='flex justify-between'>
<div class='flex gap-1 sm:gap-2'>
<div class="flex justify-between">
<div class="flex gap-1 sm:gap-2">
<a
href='/best-practices'
class='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 Best Practices'
href="/best-practices"
class="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 Best Practices"
>
&larr;<span class='hidden sm:inline'>&nbsp;All Best Practices</span>
&larr;<span class="hidden sm:inline">&nbsp;All Best Practices</span>
</a>
{
isBestPracticeReady && (
<button
data-guest-required
data-popup='login-popup'
class='hidden inline-flex 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='Download Roadmap'
data-popup="login-popup"
class="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="Download Roadmap"
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
<Icon icon="download" />
<span class="ml-2 hidden sm:inline">Download</span>
</button>
)
}
@@ -53,25 +62,25 @@ const isBestPracticeReady = !isUpcoming;
isBestPracticeReady && (
<a
data-auth-required
class='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='Download Roadmap'
class="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="Download Roadmap"
target="_blank"
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
<Icon icon="download" />
<span class="ml-2 hidden sm:inline">Download</span>
</a>
)
}
<button
data-guest-required
data-popup='login-popup'
class='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'
data-popup="login-popup"
class="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"
>
<Icon icon='email' />
<span class='ml-2'>Subscribe</span>
<Icon icon="email" />
<span class="ml-2">Subscribe</span>
</button>
</div>
@@ -79,13 +88,13 @@ const isBestPracticeReady = !isUpcoming;
isBestPracticeReady && (
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center 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='Suggest Changes'
target="_blank"
class="inline-flex items-center justify-center 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="Suggest Changes"
>
<Icon icon='comment' class='h-3 w-3' />
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
<span class='ml-2 inline sm:hidden'>Suggest</span>
<Icon icon="comment" class="h-3 w-3" />
<span class="ml-2 hidden sm:inline">Suggest Changes</span>
<span class="ml-2 inline sm:hidden">Suggest</span>
</a>
)
}

View File

@@ -1,20 +1,10 @@
---
import ResourceProgressStats from './ResourceProgressStats.astro';
export interface Props {
bestPracticeId: string;
}
---
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
<!-- Desktop: Roadmap Resources - Alert -->
<div class='hidden sm:flex justify-between px-2 bg-white items-center rounded-md p-1.5'>
<p class='text-sm'>
<span class='text-yellow-900 bg-yellow-200 py-0.5 px-1 text-xs rounded-sm font-medium uppercase mr-0.5'>Tip</span>
Click the best practices for details and resources
</p>
</div>
<!-- Mobile - Roadmap resources alert -->
<p class='block sm:hidden text-sm border border-yellow-500 text-yellow-700 rounded-md py-1.5 px-2 bg-white relative'>
Click the best practices for details and resources
</p>
<ResourceProgressStats />
</div>

View File

@@ -6,36 +6,67 @@ import GuideIcon from '../../icons/guide.svg';
import HomeIcon from '../../icons/home.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
import UserIcon from '../../icons/user.svg';
import GroupIcon from '../../icons/group.svg';
import VideoIcon from '../../icons/video.svg';
import { httpGet } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
type PageType = {
export type PageType = {
id: string;
url: string;
title: string;
group: string;
icon?: string;
isProtected?: boolean;
metadata?: Record<string, any>;
};
const defaultPages: PageType[] = [
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{
id: 'account',
url: '/account',
title: 'Account',
group: 'Pages',
icon: UserIcon,
isProtected: true,
},
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
{
id: 'team',
url: '/team',
title: 'Teams',
group: 'Pages',
icon: GroupIcon,
isProtected: true,
},
{
id: 'roadmaps',
url: '/roadmaps',
title: 'Roadmaps',
group: 'Pages',
icon: RoadmapIcon,
},
{
id: 'best-practices',
url: '/best-practices',
title: 'Best Practices',
group: 'Pages',
icon: BestPracticesIcon,
},
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
{
id: 'guides',
url: '/guides',
title: 'Guides',
group: 'Pages',
icon: GuideIcon,
},
{
id: 'videos',
url: '/videos',
title: 'Videos',
group: 'Pages',
icon: VideoIcon,
},
];
function shouldShowPage(page: PageType) {
@@ -188,7 +219,7 @@ export function CommandMenu() {
<span class="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && (
<img src={page.icon} class="mr-2 h-4 w-4" />
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
)}
{page.title}
</a>

View File

@@ -0,0 +1,216 @@
import { useEffect, useState } from 'preact/hooks';
import { Stepper } from '../Stepper';
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';
import { pageProgressMessage } from '../../stores/page';
import type { TeamResourceConfig } from './RoadmapSelector';
import { Step3 } from './Step3';
import { Step4 } from './Step4';
import {useToast} from "../../hooks/use-toast";
export interface TeamDocument {
_id?: string;
name: string;
avatar?: string;
creatorId: string;
links: {
website?: string;
github?: string;
linkedIn?: string;
};
type: ValidTeamType;
canMemberSendInvite: boolean;
teamSize?: ValidTeamSize;
createdAt: Date;
updatedAt: Date;
}
export function CreateTeamForm() {
// Can't use hook `useParams` because it runs asynchronously
const { s: queryStepIndex, t: teamId } = getUrlParams();
const toast = useToast();
const [team, setTeam] = useState<TeamDocument>();
const [loadingTeam, setLoadingTeam] = useState(!!teamId && !team?._id);
const [stepIndex, setStepIndex] = useState(0);
async function loadTeam(
teamIdToFetch: string,
requiredStepIndex: number | string
) {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
);
if (error || !response) {
toast.error(error?.message || 'Error loading team');
window.location.href = '/account';
return;
}
const requiredStepIndexNumber = parseInt(requiredStepIndex as string, 10);
const completedSteps = Array(requiredStepIndexNumber)
.fill(1)
.map((_, counter) => counter);
setTeam(response);
setSelectedTeamType(response.type);
setCompletedSteps(completedSteps);
setStepIndex(requiredStepIndexNumber);
await loadTeamResourceConfig(teamIdToFetch);
}
const [teamResourceConfig, setTeamResourceConfig] =
useState<TeamResourceConfig>([]);
async function loadTeamResourceConfig(teamId: string) {
const { error, response } = await httpGet<TeamResourceConfig>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
);
if (error || !Array.isArray(response)) {
console.error(error);
return;
}
setTeamResourceConfig(response);
}
useEffect(() => {
if (!teamId || !queryStepIndex || team) {
return;
}
pageProgressMessage.set('Fetching team');
setLoadingTeam(true);
loadTeam(teamId, queryStepIndex).finally(() => {
setLoadingTeam(false);
pageProgressMessage.set('');
});
// fetch team and move to step
}, [teamId, queryStepIndex]);
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
team?.type || 'company'
);
const [completedSteps, setCompletedSteps] = useState([0]);
if (loadingTeam) {
return null;
}
let stepForm = null;
if (stepIndex === 0) {
stepForm = (
<Step0
team={team}
selectedTeamType={selectedTeamType}
setSelectedTeamType={setSelectedTeamType}
onStepComplete={() => {
if (team?._id) {
setUrlParams({ t: team._id, s: '1' });
}
setCompletedSteps([0]);
setStepIndex(1);
}}
/>
);
} else if (stepIndex === 1) {
stepForm = (
<Step1
team={team}
onBack={() => {
if (team?._id) {
setUrlParams({ t: team._id, s: '0' });
}
setStepIndex(0);
}}
onStepComplete={(team: TeamDocument) => {
const createdTeamId = team._id!;
setUrlParams({ t: createdTeamId, s: '2' });
setCompletedSteps([0, 1]);
setStepIndex(2);
setTeam(team);
}}
selectedTeamType={selectedTeamType}
/>
);
} else if (stepIndex === 2) {
stepForm = (
<Step2
team={team!}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
onBack={() => {
if (team) {
setUrlParams({ t: team._id!, s: '1' });
}
setStepIndex(1);
}}
onNext={() => {
setUrlParams({ t: teamId!, s: '3' });
setCompletedSteps([0, 1, 2]);
setStepIndex(3);
}}
/>
);
} else if (stepIndex === 3) {
stepForm = (
<Step3
team={team}
onBack={() => {
if (team) {
setUrlParams({ t: team._id!, s: '2' });
}
setStepIndex(2);
}}
onNext={() => {
if (team) {
setUrlParams({ t: team._id!, s: '4' });
}
setCompletedSteps([0, 1, 2, 3]);
setStepIndex(4);
}}
/>
);
} else if (stepIndex === 4) {
stepForm = <Step4 team={team!} />;
}
return (
<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 flex w-full">
<Stepper
activeIndex={stepIndex}
completeSteps={completedSteps}
steps={[
{ label: 'Type' },
{ label: 'Details' },
{ label: 'Skills' },
{ label: 'Members' },
]}
/>
</div>
{stepForm}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Spinner } from '../ReactIcons/Spinner';
type NextButtonProps = {
isLoading?: boolean;
loadingMessage?: string;
text: string;
hasNextArrow?: boolean;
onClick?: () => void;
type?: string;
};
export function NextButton(props: NextButtonProps) {
const {
isLoading = false,
text = 'Next Step',
type = 'button',
loadingMessage = 'Please wait ..',
onClick = () => null,
hasNextArrow = true,
} = props;
return (
<button
type={type}
onClick={onClick}
disabled={isLoading}
className={
'rounded-md border border-black bg-black px-4 py-2 text-white disabled:opacity-50'
}
>
{isLoading ? (
<span className={'flex items-center justify-center'}>
<Spinner />
<span className="ml-2">{loadingMessage}</span>
</span>
) : (
<>
{text}
{hasNextArrow && <span className="ml-1">&rarr;</span>}
</>
)}
</button>
);
}

View File

@@ -0,0 +1,47 @@
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}
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
/>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPut } from '../../lib/http';
import type { PageType } from '../CommandMenu/CommandMenu';
import ChevronDownIcon from '../../icons/chevron-down.svg';
import { pageProgressMessage } from '../../stores/page';
import type { TeamDocument } from './CreateTeamForm';
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
import { SelectRoadmapModal } from './SelectRoadmapModal';
import { NotDropdown } from './NotDropdown';
export type TeamResourceConfig = {
resourceId: string;
resourceType: string;
removed: string[];
}[];
type RoadmapSelectorProps = {
teamId: string;
teamResourceConfig: TeamResourceConfig;
setTeamResourceConfig: (config: TeamResourceConfig) => void;
};
export function RoadmapSelector(props: RoadmapSelectorProps) {
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [error, setError] = useState<string>('');
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
setError(error.message || 'Something went wrong. Please try again!');
return;
}
if (!response) {
return [];
}
const allRoadmaps = response
.filter((page) => page.group === 'Roadmaps')
.sort((a, b) => {
if (a.title === 'Android') return 1;
return a.title.localeCompare(b.title);
});
setAllRoadmaps(allRoadmaps);
return response;
}
async function deleteResource(roadmapId: string) {
if (!teamId) {
return;
}
pageProgressMessage.set(`Deleting resource`);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-delete-team-resource-config/${teamId}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
);
if (error || !response) {
setError(error?.message || 'Error deleting roadmap');
return;
}
setTeamResourceConfig(response);
}
async function onRemove(resourceId: string) {
pageProgressMessage.set('Removing roadmap');
deleteResource(resourceId).finally(() => {
pageProgressMessage.set('');
});
}
async function addTeamResource(roadmapId: string) {
if (!teamId) {
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}`,
{
teamId: teamId,
resourceId: roadmapId,
resourceType: 'roadmap',
removed: [],
}
);
if (error || !response) {
setError(error?.message || 'Error adding roadmap');
return;
}
setTeamResourceConfig(response);
}
useEffect(() => {
loadAllRoadmaps().finally();
}, []);
return (
<div>
{changingRoadmapId && (
<UpdateTeamResourceModal
onClose={() => setChangingRoadmapId('')}
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={teamId}
setTeamResourceConfig={setTeamResourceConfig}
defaultRemovedItems={
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
)}
{showSelectRoadmapModal && (
<SelectRoadmapModal
onClose={() => setShowSelectRoadmapModal(false)}
teamResourceConfig={teamResourceConfig}
allRoadmaps={allRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId) => {
addTeamResource(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}}
onRoadmapRemove={(roadmapId) => {
onRemove(roadmapId).finally(() => {});
}}
/>
)}
<div className="mt-3">
<NotDropdown
onClick={() => {
setShowSelectRoadmapModal(true);
}}
selectedCount={teamResourceConfig.length}
singularName={'roadmap'}
pluralName={'roadmaps'}
/>
</div>
{!teamResourceConfig.length && (
<p className={'mb-3 mt-2 text-base text-gray-400'}>
No roadmaps selected.
</p>
)}
{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>
)}
</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>
);
}

View File

@@ -0,0 +1,135 @@
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
import { useRef, useState } from 'preact/hooks';
import { useOutsideClick } from '../../hooks/use-outside-click';
const allowedRoles = [
{
name: 'Admin',
value: 'admin',
description: 'Can do everything',
},
{
name: 'Manager',
value: 'manager',
description: 'Can manage team and skills',
},
{
name: 'Member',
value: 'member',
description: 'Can view team and skills',
},
] as const;
export type AllowedRoles = (typeof allowedRoles)[number]['value'];
type RoleDropdownProps = {
className?: string;
selectedRole: string;
setSelectedRole: (role: AllowedRoles) => void;
};
export function RoleDropdown(props: RoleDropdownProps) {
const { selectedRole, setSelectedRole, className = 'w-[120px]' } = props;
const dropdownRef = useRef(null);
const [activeRoleIndex, setActiveRoleIndex] = useState(0);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useOutsideClick(dropdownRef, () => {
setIsMenuOpen(false);
});
return (
<div className={`relative ${className}`}>
<button
type={'button'}
onKeyDown={(e) => {
const isUpOrDown = e.key === 'ArrowUp' || e.key === 'ArrowDown';
if (isUpOrDown && !isMenuOpen) {
e.preventDefault();
setIsMenuOpen(true);
return;
}
const isEnter = e.key === 'Enter';
if (isEnter && isMenuOpen) {
e.preventDefault();
setSelectedRole(allowedRoles[activeRoleIndex].value);
setIsMenuOpen(false);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveRoleIndex((prev) => {
const nextIndex = prev + 1;
if (nextIndex >= allowedRoles.length) {
return 0;
}
return nextIndex;
});
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveRoleIndex((prev) => {
const nextIndex = prev - 1;
if (nextIndex < 0) {
return allowedRoles.length - 1;
}
return nextIndex;
});
}
}}
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`flex h-full w-full cursor-default items-center justify-between rounded-md border px-4 ${
isMenuOpen ? 'border-gray-300 bg-gray-100' : ''
}`}
>
<span
className={`capitalize ${
selectedRole === 'admin' ? 'text-blue-600' : ''
} ${selectedRole === 'manager' ? 'text-cyan-600' : ''}`}
>
{selectedRole || 'Select Role'}
</span>
<ChevronDownIcon
className={'relative top-0.5 ml-2 h-4 w-4 text-gray-400'}
/>
</button>
{isMenuOpen && (
<div
className="absolute z-10 mt-1 w-[200px] rounded-md border bg-white shadow-md"
ref={dropdownRef}
>
<div
className="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
{allowedRoles.map((allowedRole, roleCounter) => (
<button
key={allowedRole.value}
type={'button'}
className={`w-full cursor-default px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
roleCounter === activeRoleIndex ? 'bg-gray-100' : 'bg-white'
}`}
role="menuitem"
onClick={() => {
setIsMenuOpen(false);
setSelectedRole(allowedRole.value);
}}
>
<span className="block font-medium">{allowedRole.name}</span>
<span className="block text-xs text-gray-400">
{allowedRole.description}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { useEffect, useRef, useState } from 'preact/hooks';
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 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-2xl p-4 md:h-auto">
<div
ref={popupBodyEl}
class="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} className="h-4 w-4" />
<span class="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
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
title={roadmap.title}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
onRoadmapRemove(roadmap.id);
} else {
onRoadmapAdd(roadmap.id);
}
}}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
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">
&times;
</span>
)}
{!isSelected && (
<span className="flex items-center bg-gray-100 px-2.5 text-xs text-gray-500">
+
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,122 @@
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 'preact/hooks';
import { NextButton } from './NextButton';
export const validTeamTypes = [
{
value: 'company',
label: 'Company',
icon: BuildingIcon,
description: 'Track the skills and learning progress of the tech team at your company',
},
{
value: 'study_group',
label: 'Study Group',
icon: UsersIcon,
description: 'Invite your friends or course-mates and track your learning progress together',
},
] as const;
export type ValidTeamType = (typeof validTeamTypes)[number]['value'];
type Step0Props = {
team?: TeamDocument;
selectedTeamType: ValidTeamType;
setSelectedTeamType: (teamType: ValidTeamType) => void;
onStepComplete: () => void;
};
export function Step0(props: Step0Props) {
const { team, selectedTeamType, onStepComplete, setSelectedTeamType } = props;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
async function onNextClick() {
if (!team) {
onStepComplete();
return;
}
setIsLoading(true);
setError('');
const { response, error } = await httpPut(
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
{
name: team.name,
website: team?.links?.website || undefined,
type: selectedTeamType,
gitHubUrl: team?.links?.github || undefined,
...(selectedTeamType === 'company' && {
teamSize: team.teamSize,
linkedInUrl: team?.links?.linkedIn || undefined,
}),
}
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
setIsLoading(false);
setError('');
onStepComplete();
}
return (
<>
<div className={'flex flex-row gap-3'}>
{validTeamTypes.map((validTeamType) => (
<button
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'
}`}
onClick={() => setSelectedTeamType(validTeamType.value)}
>
<img
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">
{validTeamType.label}
</span>
<span className="text-sm text-gray-500 leading-[21px]">
{validTeamType.description}
</span>
</button>
))}
</div>
{/*Error message*/}
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
<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-red-500'
}
>
Cancel
</a>
<NextButton
type={'button'}
onClick={onNextClick}
isLoading={isLoading}
text={'Next Step'}
loadingMessage={'Updating team ..'}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,260 @@
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',
'201-500',
'501-1000',
'1000+',
] as const;
export type ValidTeamSize = (typeof validTeamSizes)[number];
type Step1Props = {
team?: TeamDocument;
selectedTeamType: ValidTeamType;
onStepComplete: (team: TeamDocument) => void;
onBack: () => void;
};
export function Step1(props: Step1Props) {
const { team, selectedTeamType, onBack, onStepComplete } = props;
const [error, setError] = useState('');
const nameRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!nameRef.current) {
return;
}
nameRef.current.focus();
}, [nameRef]);
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState(team?.name || '');
const [website, setWebsite] = useState(team?.links?.website || '');
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
team?.teamSize || ('' as any)
);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
if (!name || !selectedTeamType) {
setIsLoading(false);
return;
}
let response: TeamDocument | undefined;
let error: AppError | undefined;
if (!team?._id) {
({ response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-create-team`,
{
name,
website: website || undefined,
type: selectedTeamType,
gitHubUrl: gitHubUrl || undefined,
...(selectedTeamType === 'company' && {
teamSize,
linkedInUrl: linkedInUrl || undefined,
}),
roadmapIds: [],
bestPracticeIds: [],
}
));
if (error || !response?._id) {
setError(error?.message || 'Something went wrong. Please try again.');
setIsLoading(false);
return;
}
onStepComplete(response as TeamDocument);
} else {
({ response, error } = await httpPut(
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
{
name,
website: website || undefined,
type: selectedTeamType,
gitHubUrl: gitHubUrl || undefined,
...(selectedTeamType === 'company' && {
teamSize,
linkedInUrl: linkedInUrl || undefined,
}),
}
));
if (error || (response as any)?.status !== 'ok') {
setError(error?.message || 'Something went wrong. Please try again.');
setIsLoading(false);
return;
}
onStepComplete({
...team,
name,
_id: team._id,
links: {
website: website || team?.links?.website,
linkedIn: linkedInUrl || team?.links?.linkedIn,
github: gitHubUrl || team?.links?.github,
},
type: selectedTeamType,
teamSize: teamSize!,
});
}
};
return (
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
for="name"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
</label>
<input
type="text"
name="name"
ref={nameRef as any}
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."
disabled={isLoading}
required
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
</div>
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
for="website"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Website
</label>
<input
type="url"
name="website"
required
id="website"
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="https://roadmap.sh"
disabled={isLoading}
value={website}
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
/>
</div>
)}
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
Company LinkedIn URL
</label>
<input
type="url"
name="website"
id="website"
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="https://www.linkedin.com/company/roadmapsh"
disabled={isLoading}
value={linkedInUrl}
onInput={(e) =>
setLinkedInUrl((e.target as HTMLInputElement).value)
}
/>
</div>
)}
<div className="mt-4 flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
GitHub Organization URL
</label>
<input
type="url"
name="website"
id="website"
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="https://github.com/roadmapsh"
disabled={isLoading}
value={gitHubUrl}
onInput={(e) => setGitHubUrl((e.target as HTMLInputElement).value)}
/>
</div>
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
for="team-size"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Tech Team Size
</label>
<select
name="team-size"
id="team-size"
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"
required={selectedTeamType === 'company'}
disabled={isLoading}
value={teamSize}
onChange={(e) =>
setTeamSize((e.target as HTMLSelectElement).value as any)
}
>
<option value="" selected>
Select team size
</option>
{validTeamSizes.map((size) => (
<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-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
className={
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
}
>
<span className="mr-1">&larr;</span>
Previous Step
</button>
<NextButton
isLoading={isLoading}
text={'Next Step'}
type={'submit'}
loadingMessage={'Creating team ..'}
/>
</div>
</form>
);
}

View File

@@ -0,0 +1,58 @@
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
import type { TeamDocument } from './CreateTeamForm';
type Step2Props = {
team: TeamDocument;
teamResourceConfig: TeamResourceConfig;
setTeamResourceConfig: (config: TeamResourceConfig) => void;
onBack: () => void;
onNext: () => void;
};
export function Step2(props: Step2Props) {
const { team, onBack, onNext, teamResourceConfig, setTeamResourceConfig } =
props;
return (
<>
<div className="mt-4 flex w-full flex-col">
<div className="mb-1 mt-2">
<h2 className="mb-1.5 text-2xl font-bold">Select Roadmaps</h2>
<p className="text-sm text-gray-700">
You can always add and customize your roadmaps later.
</p>
</div>
<RoadmapSelector
teamId={team._id!}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
/>
</div>
<div className="mt-4 flex flex-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
className={
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
}
>
<span className="mr-1">&larr;</span>
Previous Step
</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">&rarr;</span>
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,198 @@
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
type Step3Props = {
team?: TeamDocument;
onNext: () => void;
onBack: () => void;
};
type InviteType = {
id: string;
email: string;
role: AllowedRoles;
};
function generateId() {
return `${new Date().getTime()}`;
}
export function Step3(props: Step3Props) {
const { onNext, onBack, team } = props;
const [error, setError] = useState('');
const [invitingTeam, setInvitingTeam] = useState(false);
const emailInputRef = useRef(null);
const [users, setUsers] = useState<InviteType[]>([
{
id: generateId(),
email: '',
role: 'member',
},
]);
async function inviteTeam() {
setInvitingTeam(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-invite-team/${team?._id}`,
{
members: users,
}
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
setInvitingTeam(false);
return;
}
onNext();
}
function focusLastEmailInput() {
if (!emailInputRef.current) {
return;
}
(emailInputRef.current as HTMLInputElement).focus();
}
function onSubmit(e: any) {
e.preventDefault();
inviteTeam().finally(() => null);
}
useEffect(() => {
focusLastEmailInput();
}, [users.length]);
return (
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
<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>
</div>
<div className="mt-4 flex flex-col gap-1">
{users.map((user, userCounter) => {
return (
<div className="flex flex-row gap-2" key={user.id}>
<input
ref={userCounter === users.length - 1 ? emailInputRef : null}
autofocus={true}
type="email"
name="email"
required
id="email"
placeholder="Email"
value={user.email}
onChange={(e) => {
const newUsers = users.map((u) => {
if (u.id === user.id) {
return {
...u,
email: (e.target as HTMLInputElement)?.value,
};
}
return u;
});
setUsers(newUsers);
}}
className="flex-grow rounded-md border border-gray-200 bg-white px-4 py-2 text-gray-900"
/>
<RoleDropdown
selectedRole={user.role}
setSelectedRole={(role: AllowedRoles) => {
const newUsers = users.map((u) => {
if (u.id === user.id) {
return {
...u,
role,
};
}
return u;
});
setUsers(newUsers);
}}
/>
<button
disabled={users.length <= 1}
type="button"
className="rounded-md border border-red-200 bg-white px-4 py-2 text-red-500 hover:bg-red-100 disabled:opacity-30"
onClick={() => {
setUsers(users.filter((u) => u.id !== user.id));
}}
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
);
})}
</div>
{users.length <= 30 && (
<button
onClick={() => {
setUsers([
...users,
{ id: generateId(), email: '', role: 'member' },
]);
}}
type="button"
className="mt-2 rounded-md border border-dashed border-gray-400 py-2 text-sm text-gray-500 hover:border-gray-500 hover:text-gray-800"
>
+ Add another
</button>
)}
{error && (
<div className="mt-2 text-sm font-medium text-red-500" role="alert">
{error}
</div>
)}
<div className="mt-4 flex flex-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}
className={
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
}
>
<span className="mr-1">&larr;</span>
Previous Step
</button>
<div className={'flex gap-2'}>
<button
type="button"
onClick={onNext}
className={
'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
</button>
<NextButton
type={'submit'}
isLoading={invitingTeam}
text={'Send Invites'}
loadingMessage={'Updating team ..'}
hasNextArrow={false}
/>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,26 @@
import { CheckIcon } from '../ReactIcons/CheckIcon';
import type { TeamDocument } from './CreateTeamForm';
type Step4Props = {
team: TeamDocument;
};
export function Step4({ team }: Step4Props) {
return (
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
<div class="mb-1 flex flex-col items-center">
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
<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}`}
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
>
View Team
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { Spinner } from '../ReactIcons/Spinner';
import { httpGet, httpPut } from '../../lib/http';
import { renderTopicProgress } from '../../lib/resource-progress';
import '../FrameRenderer/FrameRenderer.css';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamResourceConfig } from './RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
export type ProgressMapProps = {
teamId: string;
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
defaultRemovedItems?: string[];
setTeamResourceConfig: (config: TeamResourceConfig) => void;
onClose: () => void;
};
export function UpdateTeamResourceModal(props: ProgressMapProps) {
const {
defaultRemovedItems = [],
resourceId,
resourceType,
teamId,
setTeamResourceConfig,
onClose,
} = props;
const containerEl = useRef<HTMLDivElement>(null);
const popupBodyEl = useRef<HTMLDivElement>(null);
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [removedItems, setRemovedItems] =
useState<string[]>(defaultRemovedItems);
useEffect(() => {
function onTopicClick(e: any) {
const groupEl = e.target.closest('.clickable-group');
const groupId = groupEl?.dataset?.groupId;
if (!groupId) {
return;
}
const normalizedGroupId = groupId.replace(/^\d+-/, '');
if (removedItems.includes(normalizedGroupId)) {
setRemovedItems((prev) =>
prev.filter((id) => id !== normalizedGroupId)
);
renderTopicProgress(normalizedGroupId, 'reset' as any);
} else {
setRemovedItems((prev) => [...prev, normalizedGroupId]);
renderTopicProgress(normalizedGroupId, 'removed');
}
}
document.addEventListener('click', onTopicClick);
return () => {
document.removeEventListener('click', onTopicClick);
};
}, [removedItems]);
let resourceJsonUrl = 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl);
const json = await res.json();
const svg = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
containerEl.current?.replaceChildren(svg);
// Render team configuration
removedItems.forEach((topicId: string) => {
renderTopicProgress(topicId, 'removed');
});
}
useKeydown('Escape', () => {
onClose();
});
useOutsideClick(popupBodyEl, () => {
onClose();
});
async function onSaveChanges() {
if (removedItems.length === 0) {
return;
}
setIsUpdating(true);
const { error, response } = await httpPut<TeamResourceConfig>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-team-resource-config/${teamId}`,
{
teamId: teamId,
resourceId: resourceId,
resourceType: resourceType,
removed: removedItems,
}
);
if (error || !response) {
toast.error(error?.message || 'Error adding roadmap');
return;
}
setTeamResourceConfig(response);
onClose();
}
useEffect(() => {
if (
!containerEl.current ||
!resourceJsonUrl ||
!resourceId ||
!resourceType ||
!teamId
) {
return;
}
renderResource(resourceJsonUrl)
.catch((err) => {
console.error(err);
toast.error('Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, []);
return (
<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
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white shadow"
>
<div
className={
'sticky top-0 mb-3 rounded-2xl border-4 border-white bg-black p-4'
}
>
<p className="mb-2 text-gray-300">
Click and select the items to remove from the roadmap.
</p>
<div className="flex flex-row items-center gap-1.5">
<button
disabled={removedItems.length === 0}
onClick={() =>
onSaveChanges().finally(() => setIsUpdating(false))
}
className={
'rounded-md bg-blue-600 px-2.5 py-1.5 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400'
}
>
{isUpdating ? (
<span className={'flex items-center gap-1.5'}>
<Spinner
className="h-3 w-3"
innerFill="white"
isDualRing={false}
/>{' '}
Saving ..
</span>
) : (
'Save Changes'
)}
</button>
<button
onClick={onClose}
className="rounded-md bg-gray-600 px-2.5 py-1.5 text-sm text-white hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
<div ref={containerEl} className="px-4"></div>
{isLoading && (
<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"
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
---
import DeleteAccountPopup from "./DeleteAccountPopup.astro";
---
<DeleteAccountPopup />
<h2 class='text-xl font-bold sm:text-2xl'>Delete Account</h2>
<p class='mt-2 text-gray-400'>
Permanently remove your account from the roadmap.sh. This cannot be undone and all your progress and data will be lost.
</p>
<button
data-popup='delete-account-popup'
class="mt-4 w-full rounded-lg bg-red-600 py-2 text-base font-regular text-white outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
>
Delete Account
</button>

View File

@@ -0,0 +1,89 @@
import {useEffect, useState} from 'preact/hooks';
import { httpDelete } from '../../lib/http';
import { logout } from '../Navigation/navigation';
export function DeleteAccountForm() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [confirmationText, setConfirmationText] = useState('');
useEffect(() => {
setError('');
setConfirmationText('');
}, [])
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (confirmationText.toUpperCase() !== 'DELETE') {
setError('Verification text does not match');
setIsLoading(false);
return;
}
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-account`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
logout();
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
const deleteAccountPopup = document.getElementById('delete-account-popup');
deleteAccountPopup?.classList.add('hidden');
deleteAccountPopup?.classList.remove('flex');
};
return (
<form onSubmit={handleSubmit}>
<div className="my-4">
<input
type="text"
name="delete-account"
id="delete-account"
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
value={confirmationText}
onInput={(e) =>
setConfirmationText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg 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-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || confirmationText.toUpperCase() !== 'DELETE'}
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Confirm'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,17 @@
---
import Popup from '../Popup/Popup.astro';
import { DeleteAccountForm } from './DeleteAccountForm';
---
<Popup id='delete-account-popup' title='Delete Account' subtitle=''>
<div class='-mt-2.5'>
<p>
This will permanently delete your account and all your associated data
including your progress.
</p>
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
<DeleteAccountForm client:only />
</div>
</Popup>

View File

@@ -0,0 +1,134 @@
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;
};
export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
const { onClose } = props;
const toast = useToast();
const popupBodyEl = useRef<HTMLDivElement>(null);
const inputEl = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [confirmationText, setConfirmationText] = useState('');
const { teamId } = useTeamId();
useOutsideClick(popupBodyEl, () => {
onClose();
});
useKeydown('Escape', () => {
onClose();
});
useEffect(() => {
inputEl.current?.focus();
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (confirmationText.toUpperCase() !== 'DELETE') {
setError('Verification text does not match');
setIsLoading(false);
return;
}
const { response, error } = await httpDelete<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team/${teamId}`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
toast.success('Team deleted successfully');
window.location.href = '/account';
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
onClose();
};
return (
<>
<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}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h2 class="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>
<p class="-mb-2 mt-3 text-base font-medium text-black">
Please type "delete" to confirm.
</p>
<form onSubmit={handleSubmit}>
<div className="my-4">
<input
ref={inputEl}
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"
placeholder={'Type "delete" to confirm'}
required
autoFocus
value={confirmationText}
onInput={(e) =>
setConfirmationText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg 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-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={
isLoading || confirmationText.toUpperCase() !== 'DELETE'
}
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Confirm'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,45 @@
type FavoriteIconProps = {
isFavorite?: boolean;
};
export function FavoriteIcon(props: FavoriteIconProps) {
const { isFavorite } = props;
if (!isFavorite) {
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
fill="none"
className="h-3.5 w-3.5"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
/>
</svg>
);
}
return (
<svg
width="8"
height="10"
viewBox="0 0 8 10"
className="h-3.5 w-3.5"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
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"
/>
</svg>
);
}

View File

@@ -1,4 +1,6 @@
---
import AstroIcon from '../AstroIcon.astro';
import { MarkFavorite } from './MarkFavorite';
export interface FeaturedItemType {
isUpcoming?: boolean;
isNew?: boolean;
@@ -13,23 +15,29 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
<a
class:list={[
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100',
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100 overflow-hidden',
{
'opacity-50': isUpcoming,
},
]}
href={url}
>
<span class='text-slate-400'>
<span class='relative z-20 text-slate-400'>
{text}
</span>
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
client:load
/>
{
isNew && (
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-purple-300 flex items-center'>
<span class='flex h-2 w-2 mr-1.5'>
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex rounded-full h-2 w-2 bg-purple-500' />
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
<span class='mr-1.5 flex h-2 w-2'>
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
</span>
New
</span>
@@ -38,13 +46,17 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
{
isUpcoming && (
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-slate-500 flex items-center'>
<span class='flex h-2 w-2 mr-1.5'>
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-slate-500 opacity-75' />
<span class='relative inline-flex rounded-full h-2 w-2 bg-slate-600' />
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-slate-500'>
<span class='mr-1.5 flex h-2 w-2'>
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-slate-500 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-slate-600' />
</span>
Upcoming
</span>
)
}
<span
data-progress
class='absolute bottom-0 left-0 top-0 z-10 w-0 bg-[#172a3a] transition-[width] duration-300'
></span>
</a>

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'preact/hooks';
import { httpPatch } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { FavoriteIcon } from './FavoriteIcon';
import { Spinner } from '../ReactIcons/Spinner';
import { useToast } from '../../hooks/use-toast';
type MarkFavoriteType = {
resourceType: ResourceType;
resourceId: string;
favorite?: boolean;
className?: string;
};
export function MarkFavorite({
resourceId,
resourceType,
favorite,
className,
}: MarkFavoriteType) {
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isFavorite, setIsFavorite] = useState(
favorite ?? localStorage.getItem(localStorageKey) === '1'
);
async function toggleFavoriteHandler(e: Event) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isLoading) {
return;
}
setIsLoading(true);
const { error } = await httpPatch<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-mark-favorite`,
{
resourceType,
resourceId,
}
);
if (error) {
setIsLoading(false);
toast.error('Failed to update favorite status');
return;
}
// Dispatching an event instead of setting the state because
// MarkFavorite component is used in the HeroSection as well
// as featured items section. We will let the custom event
// listener set the update `useEffect`
window.dispatchEvent(
new CustomEvent('mark-favorite', {
detail: {
resourceId,
resourceType,
isFavorite: !isFavorite,
},
})
);
window.dispatchEvent(new CustomEvent('refresh-favorites', {}));
setIsLoading(false);
}
useEffect(() => {
const listener = (e: Event) => {
const {
resourceId: id,
resourceType: type,
isFavorite: fav,
} = (e as CustomEvent).detail;
if (id === resourceId && type === resourceType) {
setIsFavorite(fav);
localStorage.setItem(localStorageKey, fav ? '1' : '0');
}
};
window.addEventListener('mark-favorite', listener);
return () => {
window.removeEventListener('mark-favorite', listener);
};
}, []);
return (
<button
onClick={toggleFavoriteHandler}
tabIndex={-1}
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${
className || 'absolute right-1.5 top-1.5 z-30 focus:outline-0'
}`}
>
{isLoading ? <Spinner /> : <FavoriteIcon isFavorite={isFavorite} />}
</button>
);
}

View File

@@ -1,4 +1,5 @@
---
import AstroIcon from './AstroIcon.astro';
import Icon from './AstroIcon.astro';
---
@@ -24,10 +25,8 @@ import Icon from './AstroIcon.astro';
href='/videos'>Videos</a
>
<a
target='_blank'
rel='noopener noreferrer nofollow'
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='https://cottonbureau.com/people/roadmapsh'>Store</a
href='/about'>FAQs</a
>
<a
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
@@ -64,11 +63,24 @@ import Icon from './AstroIcon.astro';
<p>
&copy; roadmap.sh
<span class='mx-1.5'>&middot;</span>
<a href='/about' class='hover:text-white'>FAQs</a>
<span class='mx-1.5'>&middot;</span>
<a href='/terms' class='hover:text-white'>Terms</a>
<span class='mx-1.5'>&middot;</span>
<a href='/privacy' class='hover:text-white'>Privacy</a>
<span class='mx-1.5'>&middot;</span>
<a
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'
class='hover:text-white'
>
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
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' />
</a>
</p>
</div>
</div>

View File

@@ -79,6 +79,19 @@ svg .clickable-group.done[data-group-id^='check:'] rect {
user-select: none;
}
svg .removed rect {
fill: #fdfdfd !important;
stroke: #c4c4c4 !important;
}
svg .removed text {
fill: #9c9c9c !important;
}
svg .removed g, svg .removed circle, svg .removed path {
opacity: 0;
}
/************************************
Aspect ratio implementation
*************************************/
@@ -109,3 +122,7 @@ svg .clickable-group.done[data-group-id^='check:'] rect {
height: 100%;
}
}
/*.clickable-group:hover {*/
/* cursor: url(/images/cursors/add.svg) 5 5, move;*/
/*}*/

View File

@@ -2,13 +2,19 @@ import { wireframeJSONToSVG } from 'roadmap-renderer';
import { httpPost } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
import {
refreshProgressCounters,
renderResourceProgress,
renderTopicProgress,
ResourceProgressType,
ResourceType,
updateResourceProgress,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { showLoginPopup } from '../../lib/popup';
export class Renderer {
resourceId: string;
resourceType: string;
resourceType: ResourceType | string;
jsonUrl: string;
loaderHTML: string | null;
@@ -28,8 +34,10 @@ export class Renderer {
this.onDOMLoaded = this.onDOMLoaded.bind(this);
this.jsonToSvg = this.jsonToSvg.bind(this);
this.handleSvgClick = this.handleSvgClick.bind(this);
this.handleSvgRightClick = this.handleSvgRightClick.bind(this);
this.prepareConfig = this.prepareConfig.bind(this);
this.switchRoadmap = this.switchRoadmap.bind(this);
this.updateTopicStatus = this.updateTopicStatus.bind(this);
}
get loaderEl() {
@@ -161,6 +169,57 @@ export class Renderer {
this.jsonToSvg(newJsonUrl)?.then(() => {});
}
updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: this.resourceId,
resourceType: this.resourceType as ResourceType,
topicId,
},
newStatus
)
.then(() => {
renderTopicProgress(topicId, newStatus);
refreshProgressCounters();
})
.catch((err) => {
alert('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
});
return;
}
handleSvgRightClick(e: any) {
const targetGroup = e.target?.closest('g') || {};
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
if (targetGroup.classList.contains('removed')) {
return;
}
e.preventDefault();
const isCurrentStatusDone = targetGroup.classList.contains('done');
const normalizedGroupId = groupId.replace(/^\d+-/, '');
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending'
);
}
handleSvgClick(e: any) {
const targetGroup = e.target?.closest('g') || {};
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
@@ -170,6 +229,10 @@ export class Renderer {
e.stopImmediatePropagation();
if (targetGroup.classList.contains('removed')) {
return;
}
if (/^ext_link/.test(groupId)) {
const externalLink = groupId.replace('ext_link:', '');
@@ -209,6 +272,28 @@ export class Renderer {
// Remove sorting prefix from groupId
const normalizedGroupId = groupId.replace(/^\d+-/, '');
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusLearning ? 'learning' : 'pending'
);
return;
}
if (e.altKey) {
e.preventDefault();
this.updateTopicStatus(
normalizedGroupId,
!isCurrentStatusSkipped ? 'skipped' : 'pending'
);
return;
}
window.dispatchEvent(
new CustomEvent(`${this.resourceType}.topic.click`, {
detail: {
@@ -223,7 +308,7 @@ export class Renderer {
init() {
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
window.addEventListener('click', this.handleSvgClick);
// window.addEventListener('contextmenu', this.handleSvgClick);
window.addEventListener('contextmenu', this.handleSvgRightClick);
}
}

View File

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

View File

@@ -0,0 +1,124 @@
import { useEffect, useState } from 'preact/hooks';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { ProgressList } from './ProgressList';
export type UserProgressResponse = {
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
resourceTitle: string;
isFavorite: boolean;
done: number;
learning: number;
skipped: number;
total: number;
updatedAt: Date;
}[];
function renderProgress(progressList: UserProgressResponse) {
progressList.forEach((progress) => {
const href =
progress.resourceType === 'best-practice'
? `/best-practices/${progress.resourceId}`
: `/${progress.resourceId}`;
const element = document.querySelector(`a[href="${href}"]`);
if (!element) {
return;
}
window.dispatchEvent(
new CustomEvent('mark-favorite', {
detail: {
resourceId: progress.resourceId,
resourceType: progress.resourceType,
isFavorite: progress.isFavorite,
},
})
);
const totalDone = progress.done + progress.skipped;
const percentageDone = (totalDone / progress.total) * 100;
const progressBar: HTMLElement | null =
element.querySelector('[data-progress]');
if (progressBar) {
progressBar.style.width = `${percentageDone}%`;
}
});
}
export function FavoriteRoadmaps() {
const [isPreparing, setIsPreparing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState<UserProgressResponse>([]);
const [containerOpacity, setContainerOpacity] = useState(0);
function showProgressContainer() {
const heroEl = document.getElementById('hero-text')!;
if (!heroEl) {
return;
}
heroEl.classList.add('opacity-0');
setTimeout(() => {
heroEl.parentElement?.removeChild(heroEl);
setIsPreparing(false);
setTimeout(() => {
setContainerOpacity(100);
}, 50);
}, 0);
}
async function loadProgress() {
setIsLoading(true);
const { response: progressList, error } =
await httpGet<UserProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
);
if (error || !progressList) {
return;
}
setProgress(progressList);
setIsLoading(false);
showProgressContainer();
// render progress on featured items
renderProgress(progressList);
}
useEffect(() => {
loadProgress().finally(() => {
setIsLoading(false);
});
}, []);
useEffect(() => {
window.addEventListener('refresh-favorites', loadProgress);
return () => window.removeEventListener('refresh-favorites', loadProgress);
}, []);
if (isPreparing) {
return null;
}
const hasProgress = progress.length > 0;
return (
<div
class={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
>
<div className="container min-h-full">
{!isLoading && progress.length == 0 && <EmptyProgress />}
{progress.length > 0 && (
<ProgressList progress={progress} isLoading={isLoading} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
---
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
---
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
<div
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
id='hero-text'
>
<h1
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
>
Developer Roadmaps
</h1>
<p class='hidden px-4 text-lg text-gray-400 sm:block'>
<span class='font-medium text-gray-400'>roadmap.sh</span> is a community effort
to create roadmaps, guides and other educational content to help guide developers
in picking up a path and guide their learnings.
</p>
<p class='text-md block px-0 text-gray-400 sm:hidden'>
Community created roadmaps, guides and articles to help developers grow in
their career.
</p>
</div>
<FavoriteRoadmaps client:authenticated />
</div>

View File

@@ -0,0 +1,61 @@
import type { UserProgressResponse } from './FavoriteRoadmaps';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
type ProgressListProps = {
progress: UserProgressResponse;
isLoading?: boolean;
};
export function ProgressList(props: ProgressListProps) {
const { progress, isLoading = false } = props;
return (
<div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-4 flex items-center text-sm text-gray-400">
{!isLoading && (
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
)}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
Your progress and favorite roadmaps.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => {
const url =
resource.resourceType === 'roadmap'
? `/${resource.resourceId}`
: `/best-practices/${resource.resourceId}`;
const percentageDone =
((resource.skipped + resource.done) / resource.total) * 100;
return (
<a
key={resource.resourceId}
href={url}
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
>
<span className="relative z-20">{resource.resourceTitle}</span>
<span
class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }}
></span>
<MarkFavorite
resourceId={resource.resourceId}
resourceType={resource.resourceType}
favorite={resource.isFavorite}
/>
</a>
);
})}
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ import Icon from '../AstroIcon.astro';
</button>
<div
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
class='absolute right-0 z-50 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
data-account-dropdown
>
<ul>

View File

@@ -5,7 +5,11 @@ import AccountDropdown from './AccountDropdown.astro';
<div class='bg-slate-900 py-5 text-white sm:py-8'>
<nav class='container flex items-center justify-between'>
<a class='flex items-center text-lg font-medium text-white' href='/' aria-label="roadmap.sh">
<a
class='flex items-center text-lg font-medium text-white'
href='/'
aria-label='roadmap.sh'
>
<Icon icon='logo' />
</a>
@@ -26,9 +30,12 @@ import AccountDropdown from './AccountDropdown.astro';
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
</li>
<li>
<kbd data-command-menu class="hidden sm:flex items-center text-gray-400 border border-gray-800 rounded-md px-2.5 py-1 text-sm hover:bg-gray-800 hover:cursor-pointer">
<Icon icon='search' class='h-3 w-3 mr-2' />
<kbd class='font-sans mr-1'>⌘</kbd><kbd class='font-sans'>K</kbd>
<kbd
data-command-menu
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 sm:flex'
>
<Icon icon='search' class='mr-2 h-3 w-3' />
<kbd class='mr-1 font-sans'>⌘</kbd><kbd class='font-sans'>K</kbd>
</kbd>
</li>
</ul>
@@ -97,10 +104,7 @@ import AccountDropdown from './AccountDropdown.astro';
<!-- Links for logged in users -->
<li data-auth-required class='hidden'>
<a
href='/account'
class='text-xl hover:text-blue-300 md:text-lg'
>
<a href='/account' class='text-xl hover:text-blue-300 md:text-lg'>
Account
</a>
</li>

View File

@@ -17,6 +17,7 @@ function bindEvents() {
// If the user clicks on the logout button, remove the token cookie
if (dataset.logoutButton !== undefined) {
e.preventDefault();
logout();
} else if (dataset.showMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');

View File

@@ -0,0 +1,109 @@
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPatch, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
import XIcon from '../../icons/close-dark.svg';
import AcceptIcon from '../../icons/accept.svg';
import { useToast } from '../../hooks/use-toast';
interface NotificationList extends TeamMemberDocument {
name: string;
}
export function NotificationPage() {
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [notifications, setNotifications] = useState<NotificationList[]>([]);
const [error, setError] = useState('');
const lostNotifications = async () => {
const { error, response } = await httpGet<NotificationList[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setNotifications(response);
};
async function respondInvitation(status: 'accept' | 'reject', inviteId: string) {
setIsLoading(true);
setError('');
const { response, error } = await httpPatch<{ teamId: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`, {
status
});
if (error || !response) {
setError(error?.message || 'Something went wrong')
setIsLoading(false)
return;
}
if (status === 'accept') {
window.location.href = `/team/progress?t=${response.teamId}`;
} else {
window.dispatchEvent(new CustomEvent('refresh-notification', {
detail: {
count: notifications.length - 1
}
}));
setNotifications(notifications.filter((notification) => notification._id !== inviteId));
setIsLoading(false);
}
}
useEffect(() => {
lostNotifications().finally(() => {
pageProgressMessage.set('');
});
}, []);
return (
<div>
<div class="mb-8 hidden md:block">
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
<p className="mt-2 text-gray-400">Manage your notifications</p>
</div>
{
notifications.length === 0 && (
<div className="flex items-center justify-center mt-6">
<p className="text-gray-400">
No notifications, you can <a href="/team/new" className="text-blue-500 underline hover:no-underline">create a team</a> and invite your friends to join.
</p>
</div>
)
}
<div className="space-y-4">
{notifications.map((notification) => (
<div className="flex items-center justify-between rounded-md border p-2">
<div className="flex items-center space-x-4">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900">
{notification.name}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<button type="button"
disabled={isLoading}
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('accept', notification?._id!)}
>
<img src={AcceptIcon} className="h-4 w-4" />
</button>
<button type="button"
disabled={isLoading}
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('reject', notification?._id!)}
>
<img src={XIcon} className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -29,6 +29,19 @@ export function PageSponsor(props: PageSponsorProps) {
const [sponsor, setSponsor] = useState<PageSponsorType>();
const loadSponsor = async () => {
const currentPath = window.location.pathname;
if (
currentPath === '/' ||
currentPath === '/best-practices' ||
currentPath === '/roadmaps' ||
currentPath.startsWith('/guides') ||
currentPath.startsWith('/videos') ||
currentPath.startsWith('/account') ||
currentPath.startsWith('/team')
) {
return;
}
const { response, error } = await httpGet<V1GetSponsorResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
{

View File

@@ -19,6 +19,7 @@ export class Popup {
return;
}
e.preventDefault();
popupEl.classList.remove('hidden');
popupEl.classList.add('flex');
const focusEl = popupEl.querySelector('[autofocus]');

View File

@@ -0,0 +1,47 @@
---
import AstroIcon from './AstroIcon.astro';
import Popup from './Popup/Popup.astro';
---
<Popup id='progress-help' title='' subtitle=''>
<div class='-mt-2.5'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
Track your Progress
</h2>
<p class='text-sm leading-4 text-gray-600'>
Login and use one of the options listed below.
</p>
<div class='mt-4 flex flex-col gap-1.5'>
<div class='rounded-md border px-3 py-3 text-gray-500'>
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
>Option 1</span
>
<p class='text-sm'>
Click the roadmap topics and use <span class='underline'
>Update Progress</span
> dropdown to update your progress.
</p>
</div>
<div class='rounded-md border border-yellow-300 bg-yellow-50 px-3 py-3 text-gray-500'>
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
>Option 2</span
>
<p class='text-sm'>Use the keyboard shortcuts listed below.</p>
<ul class="flex flex-col gap-1 mt-3 mb-1.5">
<li class='text-sm leading-loose'>
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Right Mouse Click</kbd> to mark as Done.
</li>
<li class='text-sm leading-loose'>
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Shift</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as in progress.
</li>
<li class='text-sm leading-loose'>
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Option / Alt</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as skipped.
</li>
</ul>
</div>
</div>
</div>
</Popup>

View File

@@ -0,0 +1,20 @@
type CheckIconProps = {
additionalClasses?: string;
};
export function CheckIcon(props: CheckIconProps) {
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
return (
<svg
className={`relative ${additionalClasses}`}
stroke="currentColor"
fill="currentColor"
stroke-width="0"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"></path>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
type ChevronDownIconProps = {
className?: string;
};
export function ChevronDownIcon(props: ChevronDownIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
type CloseIconProps = {
className?: string;
};
export function CloseIcon(props: CloseIconProps) {
const { className } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className={className}
>
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
);
}

View File

@@ -0,0 +1,41 @@
type ErrorIconProps = {
additionalClasses?: string;
};
export function ErrorIcon(props: ErrorIconProps) {
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`relative ${additionalClasses}`}
>
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15 9L9 15"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 9L15 15"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,41 @@
type InfoIconProps = {
additionalClasses?: string;
};
export function InfoIcon(props: InfoIconProps) {
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`relative ${additionalClasses}`}
>
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 16V12"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 8H12.01"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,38 @@
type SpinnerProps = {
className?: string;
isDualRing?: boolean;
outerFill?: string;
innerFill?: string;
};
export function Spinner({
className = '',
isDualRing = true,
outerFill = '#404040',
innerFill = '#94a3b8',
}: SpinnerProps) {
className += className?.includes('w-') ? '' : ' w-3.5 h-3.5';
return (
<svg
className={`animate-spin ${className ?? ''}`}
viewBox="0 0 93 93"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{isDualRing && (
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
style={`fill: ${outerFill};`}
></path>
)}
<path
d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z"
style={`fill: ${innerFill};`}
></path>
</svg>
);
}

View File

@@ -0,0 +1,27 @@
type TrashIconProps = {
className?: string;
};
export function TrashIcon(props: TrashIconProps) {
const { className = '' } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className={className}
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
);
}

View File

@@ -0,0 +1,41 @@
type WarningIconProps = {
additionalClasses?: string;
};
export function WarningIcon(props: WarningIconProps) {
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={`relative ${additionalClasses}`}
>
<path
d="M21.7304 18.0002L13.7304 4.00022C13.556 3.69243 13.303 3.43641 12.9973 3.25829C12.6917 3.08017 12.3442 2.98633 11.9904 2.98633C11.6366 2.98633 11.2892 3.08017 10.9835 3.25829C10.6778 3.43641 10.4249 3.69243 10.2504 4.00022L2.25042 18.0002C2.0741 18.3056 1.98165 18.6521 1.98243 19.0047C1.98321 19.3573 2.0772 19.7035 2.25486 20.008C2.43253 20.3126 2.68757 20.5648 2.99411 20.7391C3.30066 20.9133 3.64783 21.0034 4.00042 21.0002H20.0004C20.3513 20.9999 20.6959 20.9072 20.9997 20.7315C21.3035 20.5558 21.5556 20.3033 21.7309 19.9993C21.9062 19.6954 21.9985 19.3506 21.9984 18.9997C21.9983 18.6488 21.9059 18.3041 21.7304 18.0002Z"
fill="currentColor"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 9V13"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 17H12.01"
stroke="white"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);
}

View File

@@ -0,0 +1,68 @@
---
import AstroIcon from './AstroIcon.astro';
export interface Props {
isSecondaryBanner?: boolean;
}
const { isSecondaryBanner = false } = Astro.props;
---
<div
data-progress-nums-container
class:list={[
'hidden sm:flex justify-between px-2 bg-white items-center py-1.5 relative striped-loader bg-white',
{
'rounded-bl-md rounded-br-md': isSecondaryBanner,
'rounded-md': !isSecondaryBanner,
},
]}
>
<p
class='flex text-sm opacity-0 transition-opacity duration-300'
data-progress-nums
>
<span
class='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><span data-progress-done>0</span> completed</span><span
class='mx-1.5 text-gray-400'>&middot;</span
>
<span><span data-progress-learning>0</span> in progress</span><span
class='mx-1.5 text-gray-400'>&middot;</span
>
<span><span data-progress-skipped>0</span> skipped</span><span
class='mx-1.5 text-gray-400'>&middot;</span
>
<span><span data-progress-total>0</span> Total</span>
</p>
<button
data-popup='progress-help'
class='flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black'
data-progress-nums
>
<AstroIcon icon='question' />
Track Progress
</button>
</div>
<p
data-progress-nums-container
class='striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white bg-white px-2 py-1.5 text-sm text-sm text-gray-700 sm:hidden'
>
<span data-progress-nums class='opacity-0 transition-opacity duration-300 text-gray-500'>
<span data-progress-done>0</span> of <span data-progress-total>0</span> Done
</span>
<button
data-popup='progress-help'
class='flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black'
data-progress-nums
>
<AstroIcon icon='question' />
Track Progress
</button>
</p>

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPatch } from '../lib/http';
import BuildingIcon from '../icons/building.svg';
import ErrorIcon from '../icons/error.svg';
import { pageProgressMessage } from '../stores/page';
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
import type { AllowedRoles } from './CreateTeam/RoleDropdown';
import type { AllowedMemberStatus } from './TeamDropdown/TeamDropdown';
import { isLoggedIn } from '../lib/jwt';
import { showLoginPopup } from '../lib/popup';
import { getUrlParams } from '../lib/browser';
type InvitationResponse = {
team: TeamDocument;
invite: {
_id?: string;
userId?: string;
invitedEmail?: string;
teamId: string;
role: AllowedRoles;
status: AllowedMemberStatus;
createdAt: Date;
updatedAt: Date;
};
};
export function RespondInviteForm() {
const { i: inviteId } = getUrlParams();
const [isLoadingInvite, setIsLoadingInvite] = useState(true);
const [error, setError] = useState('');
const [invite, setInvite] = useState<InvitationResponse>();
const isAuthenticated = isLoggedIn();
async function loadInvitation(inviteId: string) {
const { response, error } = await httpGet<InvitationResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation/${inviteId}`
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
return;
}
setInvite(response);
}
useEffect(() => {
if (inviteId) {
loadInvitation(inviteId).finally(() => {
pageProgressMessage.set('');
setIsLoadingInvite(false);
});
} else {
setIsLoadingInvite(false);
setError('Missing invite ID in URL');
pageProgressMessage.set('');
}
}, [inviteId]);
async function respondInvitation(status: 'accept' | 'reject') {
pageProgressMessage.set('Please wait...');
setError('');
const { response, error } = await httpPatch<{ teamId: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`,
{
status,
}
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
return;
}
if (status === 'reject') {
window.location.href = '/';
return;
}
window.location.href = `/team/progress?t=${response.teamId}`;
}
if (isLoadingInvite) {
return null;
}
if (!invite) {
return (
<div className="container text-center">
<img
alt={'error'}
src={ErrorIcon}
className="mx-auto mb-4 mt-24 w-20 opacity-20"
/>
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
<p class="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>
);
}
return (
<div className="container text-center">
<img
alt={'join team'}
src={BuildingIcon}
className="mx-auto mb-4 mt-24 w-20 opacity-20"
/>
<h2 className={'mb-1 text-2xl font-bold'}>Join Team</h2>
<p class="mb-3 text-base leading-6 text-gray-600">
You have been invited to join the team{' '}
<strong id="team-name">{invite?.team?.name}</strong>.
</p>
{!isAuthenticated && (
<div class="mx-auto w-full duration-500 sm:max-w-md">
<div class="flex w-full items-center gap-2">
<button
onClick={() => showLoginPopup()}
data-popup="login-popup"
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
>
Login to respond
</button>
</div>
</div>
)}
{isAuthenticated && (
<div className={`mx-auto w-full max-w-md`}>
<div className="flex w-full items-center gap-2">
<button
type="button"
onClick={() =>
respondInvitation('accept').finally(() => {
pageProgressMessage.set('');
})
}
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
>
Accept
</button>
<button
type="button"
onClick={() =>
respondInvitation('reject').finally(() => {
pageProgressMessage.set('');
})
}
className="flex-grow cursor-pointer rounded-lg bg-red-500 px-3 py-2 text-white disabled:opacity-40"
>
Reject
</button>
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
{error}
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useCopyText } from '../../hooks/use-copy-text';
import CopyIcon from '../../icons/copy.svg';
type EditorProps = {
title: string;
text: string;
};
export function Editor(props: EditorProps) {
const { text, title } = props;
const { isCopied, copyText } = useCopyText();
return (
<div className="flex w-full flex-grow flex-col overflow-hidden rounded border border-gray-300 bg-gray-50">
<div className="flex items-center justify-between gap-2 border-b border-gray-300 px-3 py-2">
<span className="text-xs uppercase leading-none text-gray-400">
{title}
</span>
<button className="flex items-center" onClick={() => copyText(text)}>
{isCopied && (
<span className="mr-1 text-xs leading-none text-gray-700">
Copied!
</span>
)}
<img src={CopyIcon} alt="Copy" className="inline-block h-4 w-4" />
</button>
</div>
<textarea
className="no-scrollbar block h-12 w-full overflow-x-auto whitespace-nowrap bg-gray-200/70 p-3 text-sm text-gray-900 focus:bg-gray-50 focus:outline-0"
readOnly
onClick={(e: any) => {
e.target.select();
copyText(e.target.value);
}}
>
{text}
</textarea>
</div>
);
}

View File

@@ -0,0 +1,15 @@
export function GitHubReadmeBanner() {
return (
<p className="mt-3 rounded-md border p-2 text-sm w-full bg-yellow-100 border-yellow-400 text-yellow-900">
Add this badge to your{' '}
<a
href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
GitHub profile readme.
</a>
</p>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'preact/hooks';
import { useCopyText } from '../../hooks/use-copy-text';
import { useAuth } from '../../hooks/use-auth';
import CopyIcon from '../../icons/copy.svg';
import { RoadmapSelect } from './RoadmapSelect';
import { GitHubReadmeBanner } from './GitHubReadmeBanner';
import { downloadImage } from '../../helper/download-image';
import { SelectionButton } from './SelectionButton';
import { StepCounter } from './StepCounter';
import { Editor } from './Editor';
type StepLabelProps = {
label: string;
};
function StepLabel(props: StepLabelProps) {
const { label } = props;
return (
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
{label}
</span>
);
}
export function RoadCardPage() {
const { isCopied, copyText } = useCopyText();
const [roadmaps, setRoadmaps] = useState<string[]>([]);
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
const user = useAuth();
if (!user) {
return null;
}
const badgeUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${version}/${user?.id}`
);
badgeUrl.searchParams.set('variant', variant);
if (roadmaps.length > 0) {
badgeUrl.searchParams.set('roadmaps', roadmaps.join(','));
}
return (
<>
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b pt-2 pb-4">
<StepCounter step={1} />
<div>
<StepLabel label="Pick progress to show (Max. 4)" />
<div className="flex flex-wrap">
<RoadmapSelect
selectedRoadmaps={roadmaps}
setSelectedRoadmaps={setRoadmaps}
/>
</div>
</div>
</div>
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
<StepCounter step={2} />
<div>
<StepLabel label="Select Mode (Dark vs Light)" />
<div className="flex gap-2">
<SelectionButton
text={'Dark'}
isDisabled={false}
isSelected={variant === 'dark'}
onClick={() => {
setVariant('dark');
}}
/>
<SelectionButton
text={'Light'}
isDisabled={false}
isSelected={variant === 'light'}
onClick={() => {
setVariant('light');
}}
/>
</div>
</div>
</div>
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
<StepCounter step={3} />
<div>
<StepLabel label="Select Version" />
<div className="flex gap-2">
<SelectionButton
text={'Tall'}
isDisabled={false}
isSelected={version === 'tall'}
onClick={() => {
setVersion('tall');
}}
/>
<SelectionButton
text={'Wide'}
isDisabled={false}
isSelected={version === 'wide'}
onClick={() => {
setVersion('wide');
}}
/>
</div>
</div>
</div>
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
<StepCounter step={4} />
<div class="flex-grow">
<StepLabel label="Share your #RoadCard with others" />
<div className={'rounded-md border bg-gray-50 p-2 text-center'}>
<a
href={badgeUrl.toString()}
target="_blank"
rel="noopener noreferrer"
className={`relative block hover:cursor-pointer ${
version === 'tall' ? ' max-w-[270px] ' : ' w-full '
}`}
>
<img src={badgeUrl.toString()} alt="RoadCard" />
</a>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
onClick={() =>
downloadImage({
url: badgeUrl.toString(),
name: 'road-card',
scale: 4,
})
}
>
Download
</button>
<button
disabled={isCopied}
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
onClick={() => copyText(badgeUrl.toString())}
>
<img alt="Copy" src={CopyIcon} className="mr-1" />
{isCopied ? 'Copied!' : 'Copy Link'}
</button>
</div>
<div className="mt-3 flex flex-col gap-3">
<Editor
title={'HTML'}
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
/>
<Editor
title={'Markdown'}
text={`[![roadmap.sh](${badgeUrl})](https://roadmap.sh)`.trim()}
/>
</div>
<GitHubReadmeBanner />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,68 @@
import { httpGet } from '../../lib/http';
import { useEffect, useState } from 'preact/hooks';
import { pageProgressMessage } from '../../stores/page';
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
import { SelectionButton } from './SelectionButton';
type RoadmapSelectProps = {
selectedRoadmaps: string[];
setSelectedRoadmaps: (updatedRoadmaps: string[]) => void;
};
export function RoadmapSelect(props: RoadmapSelectProps) {
const { selectedRoadmaps, setSelectedRoadmaps } = props;
const [progressList, setProgressList] = useState<UserProgressResponse>();
const fetchProgress = async () => {
const { response, error } = await httpGet<UserProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
);
if (error || !response) {
return;
}
setProgressList(response);
};
useEffect(() => {
fetchProgress().finally(() => {
pageProgressMessage.set('');
});
}, []);
const canSelectMore = selectedRoadmaps.length < 4;
const allProgress = progressList?.filter(
(progress) => progress.resourceType === 'roadmap'
) || [];
return (
<div className="flex flex-wrap gap-1">
{allProgress?.length === 0 && <p className={'text-sm text-gray-400 italic'}>No progress tracked so far.</p>}
{allProgress?.map((progress) => {
const isSelected = selectedRoadmaps.includes(progress.resourceId);
const canSelect = isSelected || canSelectMore;
return (
<SelectionButton
text={progress.resourceTitle}
isDisabled={!canSelect}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setSelectedRoadmaps(
selectedRoadmaps.filter(
(roadmap) => roadmap !== progress.resourceId
)
);
} else if (selectedRoadmaps.length < 4) {
setSelectedRoadmaps([...selectedRoadmaps, progress.resourceId]);
}
}}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,23 @@
type SelectionButtonProps = {
text: string;
isDisabled: boolean;
isSelected: boolean;
onClick: () => void;
};
export function SelectionButton(props: SelectionButtonProps) {
const { text, isDisabled, isSelected, onClick } = props;
return (
<button
className={`rounded-md border p-1 px-2 text-sm ${
isSelected ? ' border-gray-500 bg-gray-300 ' : ''
} ${
!isDisabled ? ' cursor-pointer ' : ' cursor-not-allowed opacity-40 '
}`}
onClick={onClick}
>
{text}
</button>
);
}

View File

@@ -0,0 +1,17 @@
type StepCounterProps = {
step: number;
};
export function StepCounter(props: StepCounterProps) {
const { step } = props;
return (
<span
className={
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-300 text-white'
}
>
{step}
</span>
);
}

View File

@@ -5,6 +5,9 @@ import RoadmapHint from './RoadmapHint.astro';
import RoadmapNote from './RoadmapNote.astro';
import TopicSearch from './TopicSearch/TopicSearch.astro';
import YouTubeAlert from './YouTubeAlert.astro';
import ProgressHelpPopup from './ProgressHelpPopup.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { TeamVersions } from './TeamVersions/TeamVersions';
export interface Props {
title: string;
@@ -32,19 +35,24 @@ const isRoadmapReady = !isUpcoming;
---
<LoginPopup />
<ProgressHelpPopup />
<div class='border-b'>
<div class='container relative py-5 sm:py-12'>
<YouTubeAlert />
<div class='mb-3 mt-0 sm:mb-4 sm:mt-4'>
<div class='mb-3 mt-0 sm:mb-4'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
<MarkFavorite
resourceId={roadmapId}
resourceType='roadmap'
className='text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0'
client:load
/>
</h1>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
</div>
<div class='flex justify-between'>
<div class='flex justify-between gap-2 sm:gap-0'>
<div class='flex gap-1 sm:gap-2'>
{
!hasSearch && (
@@ -109,20 +117,28 @@ const isRoadmapReady = !isUpcoming;
}
</div>
{
isRoadmapReady && (
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center 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='Suggest Changes'
>
<Icon icon='comment' class='h-3 w-3' />
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
<span class='ml-2 inline sm:hidden'>Suggest</span>
</a>
)
}
<div class='flex items-center gap-1 sm:gap-2'>
<TeamVersions
resourceType='roadmap'
resourceId={roadmapId}
client:only
/>
{
isRoadmapReady && (
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center 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='Suggest Changes'
>
<Icon icon='comment' class='h-3 w-3' />
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
<span class='ml-2 inline sm:hidden'>Suggest</span>
</a>
)
}
</div>
</div>
<!-- Desktop: Roadmap Resources - Alert -->

View File

@@ -1,5 +1,7 @@
---
import AstroIcon from './AstroIcon.astro';
import Icon from './AstroIcon.astro';
import ResourceProgressStats from './ResourceProgressStats.astro';
export interface Props {
roadmapId: string;
@@ -41,37 +43,5 @@ const roadmapTitle =
)
}
<!-- Desktop: Roadmap Resources - Alert -->
<div
class:list={[
'hidden sm:flex justify-between px-2 bg-white items-center',
{
'rounded-bl-md rounded-br-md': hasTNSBanner,
'rounded-md': !hasTNSBanner,
},
]}
>
<p class='text-sm'>
<span
class='mr-0.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900'
>New</span
>
Track your progress and learn by clicking roadmap items.
</p>
<a
href={`/${roadmapId}/topics`}
class='inline-flex items-center justify-center rounded-md px-1 py-1.5 text-sm font-medium text-gray-500 hover:text-black'
>
<Icon icon='search' />
<span class='ml-2'>Search Topics</span>
</a>
</div>
<!-- Mobile - Roadmap resources alert -->
<p
class='relative block rounded-md border border-yellow-500 bg-white px-2 py-1.5 text-sm text-yellow-700 sm:hidden'
>
Track your progress and learn about the topics by clicking the roadmap items.
</p>
</div>
<ResourceProgressStats isSecondaryBanner={hasTNSBanner} />
</div>

View File

@@ -0,0 +1,156 @@
import { useEffect, useRef, useState } from 'preact/hooks';
export type OptionType = {
value: string;
label: string;
};
export function SearchSelector({
options,
onSelect,
inputClassName,
searchInputId,
placeholder,
}: {
options: OptionType[];
onSelect: (data: OptionType) => void;
inputClassName?: string;
searchInputId?: string;
placeholder?: string;
}) {
const searchInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [isActive, setIsActive] = useState(false);
const [searchResults, setSearchResults] = useState<OptionType[]>([]);
const [searchedText, setSearchedText] = useState('');
const [activeCounter, setActiveCounter] = useState(0);
useEffect(() => {
if (searchedText.length === 0) {
setSearchResults(options.slice(0, 5));
return;
}
setIsActive(true);
const normalizedSearchedText = searchedText.trim().toLowerCase();
const results = options
.filter((data) => {
return data.label.toLowerCase().indexOf(normalizedSearchedText) !== -1;
})
.slice(0, 5);
setSearchResults(results);
setActiveCounter(0);
}, [searchedText]);
useEffect(() => {
setSearchResults(options.slice(0, 5));
}, [options]);
useEffect(() => {
if (isActive) {
const handleOutsideClick = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
searchInputRef.current &&
!searchInputRef.current.contains(e.target as Node)
) {
setIsActive(false);
setSearchedText('');
setSearchResults(options.slice(0, 5));
}
};
document.addEventListener('mousedown', handleOutsideClick);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
};
}
}, [isActive]);
return (
<div className="relative">
<input
ref={searchInputRef}
type="text"
id={searchInputId}
value={searchedText}
className={`w-full ${inputClassName}`}
placeholder={placeholder}
autoComplete="off"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
setSearchedText(value);
}}
onFocus={() => {
setIsActive(true);
setSearchResults(options.slice(0, 5));
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
const canGoNext = activeCounter < searchResults.length - 1;
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
} else if (e.key === 'ArrowUp') {
const canGoPrev = activeCounter > 0;
setActiveCounter(
canGoPrev ? activeCounter - 1 : searchResults.length - 1
);
} else if (e.key === 'Tab') {
if (isActive) {
e.preventDefault();
}
} else if (e.key === 'Escape') {
setSearchedText('');
setIsActive(false);
} else if (e.key === 'Enter') {
e.preventDefault();
const activeData = searchResults[activeCounter];
if (activeData) {
onSelect(activeData);
setSearchedText('');
setIsActive(false);
}
}
}}
/>
{isActive && (
<div
class="absolute top-full z-50 mt-2 w-full rounded-md bg-gray-100 px-2 py-2"
ref={dropdownRef}
>
<div className="flex flex-col">
{searchResults.length === 0 && (
<div class="p-5 text-center text-sm text-gray-400">
No results found
</div>
)}
{searchResults.map((result, counter) => {
return (
<>
<button
type="button"
class={`flex w-full items-center rounded p-2 text-sm ${
counter === activeCounter ? 'bg-gray-200' : ''
}`}
onMouseOver={() => setActiveCounter(counter)}
onClick={() => {
onSelect(result);
setSearchedText('');
setIsActive(false);
}}
>
{result.label}
</button>
</>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { CheckIcon } from './ReactIcons/CheckIcon';
type StepperStep = {
label: string;
onClick?: () => void;
};
type StepperProps = {
activeIndex: number;
completeSteps: number[];
steps: StepperStep[];
};
export function Stepper(props: StepperProps) {
const { steps, activeIndex = 0, completeSteps = [] } = props;
return (
<ol className="flex w-full items-center text-gray-500">
{steps.map((step, stepCounter) => {
const isComplete = completeSteps.includes(stepCounter);
const isActive = activeIndex === stepCounter;
const isLast = stepCounter === (steps.length - 1);
return (
<>
<li
className={`flex items-center ${
isComplete || isActive ? 'text-black' : 'text-gray-400'
}`}
>
{isComplete && (
<CheckIcon
additionalClasses={'mr-2 top-[0.5px] w-[18px] h-[18px]'}
/>
)}
{!isComplete && (
<span class="mr-2 font-semibold">{stepCounter + 1}</span>
)}
<span className="flex flex-grow">{step.label}</span>
</li>
{!isLast && (
<li className={'mx-5 flex flex-grow rounded-md bg-gray-200'}>
<span className={'h-1 w-full'} />
</li>
)}
</>
);
})}
</ol>
);
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import ChevronDown from '../../icons/dropdown.svg';
import { httpGet } from '../../lib/http';
import { useAuth } from '../../hooks/use-auth';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Spinner } from '../ReactIcons/Spinner';
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
import { $currentTeam, $teamList } from '../../stores/team';
import { useStore } from '@nanostores/preact';
import { useTeamId } from '../../hooks/use-team-id';
import { useToast } from '../../hooks/use-toast';
const allowedStatus = ['invited', 'joined', 'rejected'] as const;
export type AllowedMemberStatus = (typeof allowedStatus)[number];
export type UserTeamItem = {
_id: string;
name: string;
avatar?: string;
roadmaps: string[];
role: AllowedRoles;
status: AllowedMemberStatus;
memberId: string;
};
export type TeamListResponse = UserTeamItem[];
export function TeamDropdown() {
const user = useAuth();
const { teamId } = useTeamId();
const teamList = useStore($teamList);
const currentTeam = useStore($currentTeam);
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [showDropdown, setShowDropdown] = useState(false);
const selectedAvatar = currentTeam ? currentTeam.avatar : user?.avatar;
const selectedLabel = currentTeam ? currentTeam.name : user?.name;
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
});
async function getAllTeams() {
const { response, error } = await httpGet<TeamListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
$teamList.set(response);
}
useEffect(() => {
if (!teamId || !teamList) {
return;
}
$currentTeam.set(teamList.find((team) => team._id === teamId));
}, [teamList, teamId]);
useEffect(() => {
setIsLoading(true);
getAllTeams().finally(() => {
setIsLoading(false);
});
}, []);
const pendingTeamIds = teamList
.filter((team) => team.status === 'invited')
.map((team) => team._id);
if (
!user?.email.endsWith('@insightpartners.com') &&
!user?.email.endsWith('@roadmap.sh') &&
!['arikchangma@gmail.com', 'kamranahmed.se@gmail.com', 'stephen.chetcuti@gmail.com'].includes(user?.email!)
) {
return null;
}
return (
<div className="relative mr-2">
<button
className="flex w-full cursor-pointer items-center justify-between rounded border p-2 text-sm hover:bg-gray-100"
onClick={() => setShowDropdown(!showDropdown)}
>
{pendingTeamIds.length > 0 && (
<span className="absolute -left-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
{pendingTeamIds.length}
</span>
)}
<div className="inline-grid grid-cols-[16px_auto] items-center gap-1.5 mr-1.5">
{isLoading && <Spinner className="h-4 w-4" isDualRing={false} />}
{!isLoading && (
<img
src={
selectedAvatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL
}/${selectedAvatar}`
: '/images/default-avatar.png'
}
alt=""
className="h-4 w-4 rounded-full object-cover"
/>
)}
<span className="truncate">
{!isLoading && selectedLabel}
{isLoading && 'Loading ..'}
</span>
</div>
<img alt={'show dropdown'} src={ChevronDown} className="h-4 w-4" />
</button>
{showDropdown && (
<div
ref={dropdownRef}
className="absolute top-full z-50 mt-2 w-full rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
>
<ul>
<li>
<a
className="flex w-full cursor-pointer items-center gap-2 truncate rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
href="/account"
>
<span className="truncate">Personal Account</span>
</a>
</li>
{teamList.map((team) => {
let pageLink = '';
if (team.status === 'invited') {
pageLink = `/respond-invite?i=${team.memberId}`;
} else if (team.status === 'joined') {
pageLink = `/team/progress?t=${team._id}`;
}
return (
<li>
<a
className="flex w-full cursor-pointer items-center gap-2 rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
href={`${pageLink}`}
>
<span className="flex-grow min-w-0 truncate">{team.name}</span>
{pendingTeamIds.includes(team._id) && (
<span className="flex rounded-md bg-red-500 px-2 text-xs text-white">
Invite
</span>
)}
</a>
</li>
);
})}
</ul>
<a
className="mt-2 flex w-full cursor-pointer items-center justify-center gap-2 rounded bg-gray-100 p-2 text-sm font-medium text-slate-800 hover:opacity-90"
href="/team/new"
>
<span>+</span>
<span>New Team</span>
</a>
</div>
)}
<hr class='my-4' />
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { useTeamId } from '../../hooks/use-team-id';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
type InviteMemberPopupProps = {
onInvited: () => void;
onClose: () => void;
};
export function InviteMemberPopup(props: InviteMemberPopupProps) {
const { onClose, onInvited } = props;
const popupBodyRef = useRef<HTMLDivElement>(null);
const emailRef = useRef<HTMLInputElement>(null);
const [selectedRole, setSelectedRole] = useState<AllowedRoles>('member');
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { teamId } = useTeamId();
useEffect(() => {
emailRef?.current?.focus();
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-invite-member/${teamId}`,
{ email, role: selectedRole }
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
setIsLoading(false);
handleClosePopup();
onInvited();
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
onClose();
};
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<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={popupBodyRef}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h3 class="mb-1.5 text-xl sm:text-2xl font-medium">Invite Member</h3>
<p className="mb-3 text-sm leading-none text-gray-400 hidden sm:block">
Enter the email and role below to invite a member.
</p>
<form onSubmit={handleSubmit}>
<div className="mt-0 sm:mt-4 my-4 flex flex-col gap-2">
<input
ref={emailRef}
type="email"
name="invite-member"
id="invite-member"
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"
placeholder="Enter email address"
required
autoFocus
value={email}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
<div className="flex h-[42px] w-full flex-col">
<RoleDropdown
className="h-full w-full"
selectedRole={selectedRole}
setSelectedRole={setSelectedRole}
/>
</div>
{error && (
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm 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-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !email}
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Invite'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'preact/hooks';
import { LeaveTeamPopup } from './LeaveTeamPopup';
type LeaveTeamButtonProps = {
teamId: string;
};
export function LeaveTeamButton(props: LeaveTeamButtonProps) {
const [showLeaveTeamPopup, setShowLeaveTeamPopup] = useState(false);
return (
<>
{showLeaveTeamPopup && (
<LeaveTeamPopup
onClose={() => {
setShowLeaveTeamPopup(false);
}}
/>
)}
<button
onClick={() => {
setShowLeaveTeamPopup(true);
}}
className="flex h-7 min-w-[95px] items-center justify-center rounded-md border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm font-medium leading-none text-red-600"
>
Leave team
</button>
</>
);
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpDelete } from '../../lib/http';
import { useTeamId } from '../../hooks/use-team-id';
import { useOutsideClick } from '../../hooks/use-outside-click';
type LeaveTeamPopupProps = {
onClose: () => void;
};
export function LeaveTeamPopup(props: LeaveTeamPopupProps) {
const { onClose } = props;
const popupBodyRef = useRef<HTMLDivElement>(null);
const confirmationEl = useRef<HTMLInputElement>(null);
const [confirmationText, setConfirmationText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { teamId } = useTeamId();
useEffect(() => {
setError('');
setConfirmationText('');
confirmationEl?.current?.focus();
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
if (confirmationText.toUpperCase() !== 'LEAVE') {
setError('Verification text does not match');
setIsLoading(false);
return;
}
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-leave-team/${teamId}`,
{}
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
window.location.href = '/account';
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setConfirmationText('');
onClose();
};
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<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={popupBodyRef}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h2 class="text-2xl font-semibold text-black">
Leave Team
</h2>
<p className="text-gray-500">
You will lose access to the team, the roadmaps and progress of other team members.
</p>
<p className="-mb-2 mt-3 text-base font-medium text-black">
Please type "leave" to confirm.
</p>
<form onSubmit={handleSubmit}>
<div className="my-4">
<input
ref={confirmationEl}
type="text"
name="leave-team"
id="leave-team"
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"
placeholder={'Type "leave" to confirm'}
required
autoFocus
value={confirmationText}
onInput={(e) =>
setConfirmationText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg 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-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={
isLoading || confirmationText.toUpperCase() !== 'LEAVE'
}
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Leave Team'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useRef, useState } from 'preact/hooks';
import type { TeamMemberDocument } from './TeamMembersPage';
import { httpDelete, httpPatch } from '../../lib/http';
import MoreIcon from '../../icons/more-vertical.svg';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useToast } from '../../hooks/use-toast';
export function MemberActionDropdown({
member,
onUpdateMember,
onDeleteMember,
isDisabled = false,
}: {
onDeleteMember: () => void;
onUpdateMember: () => void;
isDisabled: boolean;
member: TeamMemberDocument;
}) {
const toast = useToast();
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
async function resendInvite() {
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-resend-invite/${member.teamId}/${
member._id
}`,
{}
);
if (error || !response) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong');
return;
}
window.location.reload();
}
const actions = [
{
name: 'Delete',
handleClick: () => {
onDeleteMember();
setIsOpen(false);
},
},
{
name: 'Update Role',
handleClick: () => {
onUpdateMember();
setIsOpen(false);
},
},
...(['invited'].includes(member.status)
? [
{
name: 'Resend Invite',
handleClick: resendInvite,
},
]
: []),
];
return (
<div className="relative">
<button
disabled={isDisabled}
onClick={() => setIsOpen(!isOpen)}
className="ml-2 flex items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
>
<img alt="menu" src={MoreIcon} className="h-4 w-4" />
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-0 top-full z-50 mt-1 w-32 rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
>
<ul>
{actions.map((action, index) => {
return (
<li key={index}>
<button
onClick={action.handleClick}
disabled={isLoading}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
{action.name}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
export function MemberRoleBadge({ role }: { role: AllowedRoles }) {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs capitalize ${
['admin'].includes(role)
? 'bg-blue-100 text-blue-700 '
: 'bg-gray-100 text-gray-700 '
} ${['manager'].includes(role) ? 'bg-green-100 text-green-700' : ''}`}
>
{role}
</span>
);
}

View File

@@ -0,0 +1,238 @@
import { useEffect, useState } from 'preact/hooks';
import { httpDelete, httpGet } from '../../lib/http';
import { MemberActionDropdown } from './MemberActionDropdown';
import { useAuth } from '../../hooks/use-auth';
import { pageProgressMessage } from '../../stores/page';
import type { TeamDocument } from '../CreateTeam/CreateTeamForm';
import { LeaveTeamButton } from './LeaveTeamButton';
import type { AllowedRoles } from '../CreateTeam/RoleDropdown';
import type { AllowedMemberStatus } from '../TeamDropdown/TeamDropdown';
import { InviteMemberPopup } from './InviteMemberPopup';
import { getUrlParams } from '../../lib/browser';
import { UpdateMemberPopup } from './UpdateMemberPopup';
import { useStore } from '@nanostores/preact';
import { $canManageCurrentTeam } from '../../stores/team';
import { useToast } from '../../hooks/use-toast';
import { MemberRoleBadge } from './RoleBadge';
export interface TeamMemberDocument {
_id?: string;
userId?: string;
invitedEmail?: string;
teamId: string;
role: AllowedRoles;
status: AllowedMemberStatus;
createdAt: Date;
updatedAt: Date;
}
interface TeamMemberItem extends TeamMemberDocument {
name: string;
avatar: string;
}
export function TeamMembersPage() {
const { t: teamId } = getUrlParams();
const toast = useToast();
const canManageCurrentTeam = useStore($canManageCurrentTeam);
const [memberToUpdate, setMemberToUpdate] = useState<TeamMemberItem>();
const [isInvitingMember, setIsInvitingMember] = useState(false);
const [teamMembers, setTeamMembers] = useState<TeamMemberItem[]>([]);
const [team, setTeam] = useState<TeamDocument>();
const user = useAuth();
async function loadTeam() {
const { response, error } = await httpGet<TeamDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
if (response) {
setTeam(response);
}
}
async function getTeamMemberList() {
const { response, error } = await httpGet<TeamMemberItem[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-member-list/${teamId}`
);
if (error || !response) {
toast.error(error?.message || 'Failed to load team member list');
return;
}
setTeamMembers(response);
}
useEffect(() => {
if (!teamId) {
return;
}
Promise.all([loadTeam(), getTeamMemberList()]).finally(() => {
pageProgressMessage.set('');
});
}, [teamId]);
async function deleteMember(teamId: string, memberId: string) {
pageProgressMessage.set('Deleting member');
const { response, error } = await httpDelete(
`${
import.meta.env.PUBLIC_API_URL
}/v1-delete-member/${teamId}/${memberId}`,
{}
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Member has been deleted');
await getTeamMemberList();
}
return (
<div>
{memberToUpdate && (
<UpdateMemberPopup
member={memberToUpdate}
onUpdated={() => {
pageProgressMessage.set('Refreshing members');
getTeamMemberList().finally(() => {
pageProgressMessage.set('');
});
setMemberToUpdate(undefined);
toast.success('Member has been updated');
}}
onClose={() => {
setMemberToUpdate(undefined);
}}
/>
)}
{isInvitingMember && (
<InviteMemberPopup
onInvited={() => {
toast.success('Invite sent');
getTeamMemberList().then(() => null);
setIsInvitingMember(false);
}}
onClose={() => {
setIsInvitingMember(false);
}}
/>
)}
<div>
<div className="rounded-b-sm rounded-t-md border">
<div className="flex items-center justify-between gap-2 border-b p-3">
<p className="hidden text-sm sm:block">
{teamMembers.length} people in the team.
</p>
<p className="block text-sm sm:hidden">
{teamMembers.length} members
</p>
<LeaveTeamButton teamId={team?._id!} />
</div>
{teamMembers.map((member, index) => {
return (
<div
className={`flex items-center justify-between gap-2 p-3 ${
index === 0 ? '' : 'border-t'
} ${member.status === 'invited' ? 'bg-gray-50' : ''}`}
>
<div className="flex items-center gap-3">
<img
src={
member.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
member.avatar
}`
: '/images/default-avatar.png'
}
alt={member.name || ''}
className="hidden h-10 w-10 rounded-full sm:block"
/>
<div>
<span class={'mb-1 block sm:hidden'}>
<MemberRoleBadge role={member.role} />
</span>
<div className="flex items-center">
<h3 className="inline-grid grid-cols-[auto_auto] items-center font-medium">
<span className="truncate">{member.name}</span>
{member.userId === user?.id && (
<span className="ml-2 hidden text-xs font-normal text-blue-500 sm:inline">
You
</span>
)}
</h3>
<div className="ml-2 flex items-center gap-0.5">
{member.status === 'invited' && (
<span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs text-yellow-700">
Invited
</span>
)}
{member.status === 'rejected' && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
Rejected
</span>
)}
</div>
</div>
<p className="text-sm text-gray-500">
{member.invitedEmail}
</p>
</div>
</div>
<div className="flex items-center text-sm">
<span class={'hidden sm:block'}>
<MemberRoleBadge role={member.role} />
</span>
{canManageCurrentTeam && (
<MemberActionDropdown
onDeleteMember={() => {
deleteMember(teamId, member._id!).finally(() => {
pageProgressMessage.set('');
});
}}
isDisabled={member.userId === user?.id}
onUpdateMember={() => {
setMemberToUpdate(member);
}}
member={member}
/>
)}
</div>
</div>
);
})}
</div>
</div>
{canManageCurrentTeam && (
<div className="mt-4">
<button
disabled={teamMembers.length >= 25}
onClick={() => setIsInvitingMember(true)}
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
>
+ Invite Member
</button>
</div>
)}
{teamMembers.length >= 25 && canManageCurrentTeam && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
You have reached the maximum number of members in a team. Please reach
out to us if you need more.
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost, httpPut } from '../../lib/http';
import { useTeamId } from '../../hooks/use-team-id';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { AllowedRoles, RoleDropdown } from '../CreateTeam/RoleDropdown';
import type { TeamMemberDocument } from './TeamMembersPage';
type InviteMemberPopupProps = {
member: TeamMemberDocument;
onUpdated: () => void;
onClose: () => void;
};
export function UpdateMemberPopup(props: InviteMemberPopupProps) {
const { onClose, onUpdated, member } = props;
const popupBodyRef = useRef<HTMLDivElement>(null);
const [selectedRole, setSelectedRole] = useState<AllowedRoles>(member.role);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { teamId } = useTeamId();
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPut(
`${import.meta.env.PUBLIC_API_URL}/v1-update-member-role/${teamId}/${
member._id
}`,
{ role: selectedRole }
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
setIsLoading(false);
onUpdated();
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
onClose();
};
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<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={popupBodyRef}
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h3 class="mb-1.5 text-xl sm:text-2xl font-medium">Update Role</h3>
<p className="mb-3 text-sm leading-none text-gray-400 hidden sm:block">
Select the role to update for this member
</p>
<form onSubmit={handleSubmit}>
<div className="mt-0 sm:mt-4 my-4 flex flex-col gap-2">
<span className="mt-2 block w-full rounded-md bg-gray-100 p-2">
{member.invitedEmail}
</span>
<div className="flex h-[42px] w-full flex-col">
<RoleDropdown
className="h-full w-full"
selectedRole={selectedRole}
setSelectedRole={setSelectedRole}
/>
</div>
{error && (
<p className=" rounded-md border border-red-300 bg-red-50 p-2 text-sm 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-lg bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading || !selectedRole}
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Update Role'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'preact/hooks';
import type { GroupByRoadmap, TeamMember } from './TeamProgressPage';
import { MemberProgressModal } from './MemberProgressModal';
import { getUrlParams } from '../../lib/browser';
import ExternalLinkIcon from '../../icons/external-link.svg';
import { useAuth } from '../../hooks/use-auth';
type GroupRoadmapItemProps = {
roadmap: GroupByRoadmap;
};
export function GroupRoadmapItem(props: GroupRoadmapItemProps) {
const { members, resourceTitle, resourceId } = props.roadmap;
const { t: teamId } = getUrlParams();
const user = useAuth();
const [showAll, setShowAll] = useState(false);
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
return (
<>
{detailResourceId && (
<MemberProgressModal
member={selectedMember!}
teamId={teamId}
resourceId={detailResourceId}
resourceType={'roadmap'}
onClose={() => {
setDetailResourceId(null);
setSelectedMember(null);
}}
/>
)}
<div className="flex h-full min-h-[270px] flex-col rounded-md border">
<div className="flex items-center gap-3 border-b p-3">
<div className="flex min-w-0 flex-grow items-center justify-between">
<h3 className="truncate font-medium">{resourceTitle}</h3>
<a
href={`/${resourceId}?t=${teamId}`}
className="group mb-0.5 flex shrink-0 items-center justify-between text-base font-medium leading-none text-black"
target={'_blank'}
>
<img
alt={'link'}
src={ExternalLinkIcon}
className="ml-2 h-4 w-4 opacity-20 transition-opacity group-hover:opacity-100"
/>
</a>
</div>
</div>
<div className="relative flex grow flex-col space-y-2 p-3">
{(showAll ? members : members.slice(0, 4)).map((member) => {
const isMyProgress = user?.email === member?.member?.email;
if (!member.progress) {
return null;
}
return (
<button
className={`group relative w-full overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none ${isMyProgress ? 'border-green-500 hover:border-green-600' : ''}`}
key={member?.member._id}
onClick={() => {
setDetailResourceId(member?.progress?.resourceId!);
setSelectedMember(member.member);
}}
>
<span className="relative z-10 flex items-center justify-between gap-1 text-sm">
<span className="inline-grid grid-cols-[20px_auto] gap-3">
<img
src={
member.member.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
member.member.avatar
}`
: '/images/default-avatar.png'
}
alt={member.member.name || ''}
className="h-5 w-5 shrink-0 rounded-full"
/>
<span className="inline-grid grid-cols-[auto,32px] items-center">
<span className="truncate mr-[5px]">{member?.member?.name}</span>
</span>
</span>
<span className="shrink-0 text-xs text-gray-400">
{member?.progress?.done} / {member?.progress?.total}
</span>
</span>
<span
className={`absolute inset-0 ${isMyProgress ? 'bg-green-100 group-hover:bg-green-200' : 'bg-gray-100 group-hover:bg-gray-200'}`}
style={{
width: `${
(member?.progress?.done / member?.progress?.total) * 100
}%`,
}}
/>
</button>
);
})}
{members.length > 4 && !showAll && (
<button
onClick={() => setShowAll(true)}
className={'text-sm text-gray-400 underline'}
>
+ {members.length - 4} more
</button>
)}
{showAll && (
<button
onClick={() => setShowAll(false)}
className={'text-sm text-gray-400 underline'}
>
- Show less
</button>
)}
{members.length === 0 && (
<div className="text-sm text-gray-500">No progress</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,118 @@
import type { TeamMember } from './TeamProgressPage';
import { useState } from 'preact/hooks';
import { MemberProgressModal } from './MemberProgressModal';
type MemberProgressItemProps = {
teamId: string;
member: TeamMember;
isMyProgress?: boolean;
};
export function MemberProgressItem(props: MemberProgressItemProps) {
const { member, teamId, isMyProgress = false } = props;
const memberProgress = member?.progress?.sort((a, b) => {
return b.done - a.done;
});
const [detailResourceId, setDetailResourceId] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
return (
<>
{detailResourceId && (
<MemberProgressModal
member={member}
teamId={teamId}
resourceId={detailResourceId}
resourceType={'roadmap'}
onClose={() => {
setDetailResourceId(null);
}}
/>
)}
<div
className={`flex h-full min-h-[270px] flex-col overflow-hidden rounded-md border`}
key={member._id}
>
<div className={`relative flex items-center gap-3 border-b p-3`}>
<img
src={
member.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${member.avatar}`
: '/images/default-avatar.png'
}
alt={member.name || ''}
className="h-8 w-8 rounded-full"
/>
<div className="inline-grid w-full">
{!isMyProgress && (
<h3 className="truncate font-medium">{member.name}</h3>
)}
{isMyProgress && (
<div className="inline-grid grid-cols-[auto,32px] items-center gap-1.5">
<h3 className="truncate font-medium">{member.name}</h3>
<span className="rounded-md bg-red-500 py-0.5 px-1 text-xs text-white">
You
</span>
</div>
)}
<p className="truncate text-sm text-gray-500">{member.email}</p>
</div>
</div>
<div className="relative flex grow flex-col space-y-2 p-3">
{(showAll ? memberProgress : memberProgress.slice(0, 4)).map(
(progress) => {
return (
<button
onClick={() => setDetailResourceId(progress.resourceId)}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
key={progress.resourceId}
>
<span className="relative z-10 flex items-center justify-between text-sm">
<span className="inline-grid">
<span className={'truncate'}>
{progress.resourceTitle}
</span>
</span>
<span className="ml-1.5 shrink-0 text-xs text-gray-400">
{progress.done} / {progress.total}
</span>
</span>
<span
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
style={{
width: `${(progress.done / progress.total) * 100}%`,
}}
/>
</button>
);
}
)}
{memberProgress.length > 4 && !showAll && (
<button
onClick={() => setShowAll(true)}
className={'text-sm text-gray-400 underline'}
>
+ {memberProgress.length - 4} more
</button>
)}
{showAll && (
<button
onClick={() => setShowAll(false)}
className={'text-sm text-gray-400 underline'}
>
- Show less
</button>
)}
{memberProgress.length === 0 && (
<div className="text-sm text-gray-500">No progress</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,395 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { Spinner } from '../ReactIcons/Spinner';
import '../FrameRenderer/FrameRenderer.css';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamMember } from './TeamProgressPage';
import { httpGet } from '../../lib/http';
import {
ResourceProgressType,
ResourceType,
renderTopicProgress,
updateResourceProgress,
} from '../../lib/resource-progress';
import CloseIcon from '../../icons/close.svg';
import { useToast } from '../../hooks/use-toast';
import { useAuth } from '../../hooks/use-auth';
import { pageProgressMessage } from '../../stores/page';
import { ProgressHint } from './ProgressHint';
import QuestionIcon from '../../icons/question.svg';
import { InfoIcon } from '../ReactIcons/InfoIcon';
export type ProgressMapProps = {
member: TeamMember;
teamId: string;
resourceId: string;
resourceType: 'roadmap' | 'best-practice';
onClose: () => void;
};
type MemberProgressResponse = {
removed: string[];
done: string[];
learning: string[];
skipped: string[];
};
export function MemberProgressModal(props: ProgressMapProps) {
const { resourceId, member, resourceType, teamId, onClose } = props;
const user = useAuth();
const isCurrentUser = user?.email === member.email;
const containerEl = useRef<HTMLDivElement>(null);
const popupBodyEl = useRef<HTMLDivElement>(null);
const [showProgressHint, setShowProgressHint] = useState(false);
const [memberProgress, setMemberProgress] =
useState<MemberProgressResponse>();
const [isLoading, setIsLoading] = useState(true);
const toast = useToast();
let resourceJsonUrl = 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
resourceJsonUrl += `/best-practices/${resourceId}.json`;
}
async function getMemberProgress(
teamId: string,
memberId: string,
resourceType: string,
resourceId: string
) {
const { error, response } = await httpGet<MemberProgressResponse>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-get-member-resource-progress/${teamId}/${memberId}?resourceType=${resourceType}&resourceId=${resourceId}`
);
if (error || !response) {
toast.error(error?.message || 'Failed to get member progress');
return;
}
setMemberProgress(response);
return response;
}
async function renderResource(jsonUrl: string) {
const res = await fetch(jsonUrl);
const json = await res.json();
const svg = await wireframeJSONToSVG(json, {
fontURL: '/fonts/balsamiq.woff2',
});
containerEl.current?.replaceChildren(svg);
}
useKeydown('Escape', () => {
if (showProgressHint) {
return;
}
onClose();
});
useOutsideClick(popupBodyEl, () => {
if (showProgressHint) {
return;
}
onClose();
});
useEffect(() => {
if (
!containerEl.current ||
!resourceJsonUrl ||
!resourceId ||
!resourceType ||
!teamId
) {
return;
}
Promise.all([
renderResource(resourceJsonUrl),
getMemberProgress(teamId, member._id, resourceType, resourceId),
])
.then(([_, memberProgress = {}]) => {
const {
removed = [],
done = [],
learning = [],
skipped = [],
} = memberProgress;
done.forEach((id: string) => renderTopicProgress(id, 'done'));
learning.forEach((id: string) => renderTopicProgress(id, 'learning'));
skipped.forEach((id: string) => renderTopicProgress(id, 'skipped'));
removed.forEach((id: string) => renderTopicProgress(id, 'removed'));
})
.catch((err) => {
console.error(err);
toast.error(err?.message || 'Something went wrong. Please try again!');
})
.finally(() => {
setIsLoading(false);
});
}, []);
function updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
if (!resourceId || !resourceType || !isCurrentUser) {
return;
}
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: resourceId,
resourceType: resourceType as ResourceType,
topicId,
},
newStatus
)
.then(() => {
renderTopicProgress(topicId, newStatus);
getMemberProgress(teamId, member._id, resourceType, resourceId).then(
(data) => {
setMemberProgress(data);
}
);
})
.catch((err) => {
alert('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
});
return;
}
async function handleRightClick(e: MouseEvent) {
const targetGroup = (e.target as HTMLElement)?.closest('g');
if (!targetGroup) {
return;
}
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
if (targetGroup.classList.contains('removed')) {
return;
}
e.preventDefault();
const isCurrentStatusDone = targetGroup.classList.contains('done');
const normalizedGroupId = groupId.replace(/^\d+-/, '');
updateTopicStatus(
normalizedGroupId,
!isCurrentStatusDone ? 'done' : 'pending'
);
}
async function handleClick(e: MouseEvent) {
const targetGroup = (e.target as HTMLElement)?.closest('g');
if (!targetGroup) {
return;
}
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
}
if (targetGroup.classList.contains('removed')) {
return;
}
e.preventDefault();
const normalizedGroupId = groupId.replace(/^\d+-/, '');
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
updateTopicStatus(
normalizedGroupId,
!isCurrentStatusLearning ? 'learning' : 'pending'
);
return;
}
if (e.altKey) {
e.preventDefault();
updateTopicStatus(
normalizedGroupId,
!isCurrentStatusSkipped ? 'skipped' : 'pending'
);
return;
}
}
useEffect(() => {
if (!isCurrentUser || !containerEl.current) {
return;
}
containerEl.current?.addEventListener('contextmenu', handleRightClick);
containerEl.current?.addEventListener('click', handleClick);
return () => {
containerEl.current?.removeEventListener('contextmenu', handleRightClick);
containerEl.current?.removeEventListener('click', handleClick);
};
}, []);
const removedTopics = memberProgress?.removed || [];
const memberDone =
memberProgress?.done.filter((id) => !removedTopics.includes(id)).length ||
0;
const memberLearning =
memberProgress?.learning.filter((id) => !removedTopics.includes(id))
.length || 0;
const memberSkipped =
memberProgress?.skipped.filter((id) => !removedTopics.includes(id))
.length || 0;
const currProgress = member.progress.find((p) => p.resourceId === resourceId);
const memberTotal = currProgress?.total || 0;
const progressPercentage = Math.round((memberDone / memberTotal) * 100);
return (
<div 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
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white shadow"
>
{showProgressHint && (
<ProgressHint
onClose={() => {
setShowProgressHint(false);
}}
/>
)}
<div className="p-4">
{isCurrentUser ? (
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
Your Progress
</h2>
<p className={'text-gray-500'}>
You can{' '}
<button
className="inline-flex items-center text-blue-600 underline"
onClick={() => {
setShowProgressHint(true);
}}
>
follow these instructions
</button>{' '}
to update your progress below.
</p>
</div>
) : (
<div className="mb-5 mt-0 text-left md:mt-4 md:text-center">
<h2 className={'mb-1 text-lg font-bold md:text-2xl'}>
{member.name}'s Progress
</h2>
<p
className={
'hidden text-xs text-gray-500 sm:text-sm md:block md:text-base'
}
>
You are looking at {member.name}'s progress.{' '}
<a
target={'_blank'}
href={`/${resourceId}?t=${teamId}`}
className="text-blue-600 underline"
>
View your progress
</a>
.
</p>
<p className={'block text-gray-500 md:hidden'}>
View your progress&nbsp;
<a
target={'_blank'}
href={`/${resourceId}?t=${teamId}`}
className="text-blue-600 underline"
>
on the roadmap page.
</a>
</p>
</div>
)}
<p class="-mx-4 mb-3 flex items-center justify-start border-b border-t px-4 py-2 text-sm sm:hidden">
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{memberDone}</span> of <span>{memberTotal}</span> done
</span>
</p>
<p class="-mx-4 mb-3 hidden items-center justify-center border-b border-t py-2 text-sm sm:flex">
<span class="mr-2.5 block rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span>{progressPercentage}</span>% Done
</span>
<span>
<span>{memberDone}</span> completed
</span>
<span class="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-learning="">{memberLearning}</span> in
progress
</span>
{memberSkipped > 0 && (
<>
<span class="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-skipped="">{memberSkipped}</span>{' '}
skipped
</span>
</>
)}
<span class="mx-1.5 text-gray-400">·</span>
<span>
<span data-progress-total="">{memberTotal}</span> Total
</span>
</p>
</div>
<div ref={containerEl} className="px-4 pb-2"></div>
{isLoading && (
<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"
/>
</div>
)}
<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-200 hover:text-gray-900 sm:hidden"
onClick={onClose}
>
<img src={CloseIcon} className="h-4 w-4" />
<span class="sr-only">Close modal</span>
</button>
</div>
</div>
</div>
);
}

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