Compare commits

...

196 Commits

Author SHA1 Message Date
Kamran Ahmed
1c0adf22b6 Add devops beginner roadmap 2023-06-10 04:01:41 +01:00
Kamran Ahmed
6a4481a3d0 Rewrite devops roadmap 2023-06-07 23:08:32 +01:00
Kamran Ahmed
557c426078 Update apollo workshop image 2023-06-07 11:19:42 +01:00
Kamran Ahmed
d61a83a0a3 chore: add resource under flutter:design-principles:solid-principles 2023-06-06 14:19:46 +01:00
Kamran Ahmed
7500c6c1cb chore: add resource under backend:internet:what-is-http 2023-06-06 14:19:16 +01:00
Kamran Ahmed
b51076dd0a chore: add resource under cpp:introduction 2023-06-06 14:18:56 +01:00
Kamran Ahmed
8010bfc832 Add kodecloud link 2023-06-06 14:17:21 +01:00
Kamran Ahmed
0f80f26d17 Update link-groups 2023-06-06 11:02:08 +01:00
thesmallrock
40d25c43f4 Fixing "new and delete operators" titles. (#3994) 2023-06-06 09:32:02 +01:00
Kamran Ahmed
686a7382ab chore: add resource under cyber-security:basic-it-skills:basics-of-computer-networking 2023-06-06 09:11:42 +01:00
Kamran Ahmed
88401bd7b1 chore: add resource under mongodb:datatypes:date 2023-06-06 09:11:12 +01:00
Kamran Ahmed
1d97467c05 chore: add resource under software-design-architecture:architectural-styles 2023-06-06 09:10:43 +01:00
Kamran Ahmed
2388fa148b Update apollo workshop 2023-06-05 23:40:10 +01:00
Kamran Ahmed
d574fccbc8 Add apollo asset 2023-06-05 23:21:04 +01:00
Kamran Ahmed
89cc55a1eb chore: add resource under flutter:state-management:change-notifier 2023-06-05 21:18:29 +01:00
Kamran Ahmed
8c75354235 chore: add resource under flutter:state-management:value-notifier 2023-06-05 21:18:22 +01:00
Kamran Ahmed
9eb9dc8cd8 chore: add resource under nodejs:nodejs-command-line-apps:taking-input 2023-06-05 21:18:07 +01:00
Kamran Ahmed
afa28bddd3 chore: add resource under python:python-advanced-topics:lambdas 2023-06-05 21:17:32 +01:00
Kamran Ahmed
5cf0e76765 chore: add resource under react:components:props-vs-state 2023-06-05 21:16:59 +01:00
Kamran Ahmed
16b3f8ff49 chore: add resource under system-design:application-layer:microservices 2023-06-05 21:16:12 +01:00
Kamran Ahmed
d2055e0f6d chore: add resource under react:components:conditional-rendering 2023-06-05 21:15:43 +01:00
Kamran Ahmed
4010157baf chore: add resource under ux-design:human-decision-making:frameworks:bj-frogg-behavior-model 2023-06-05 21:15:08 +01:00
Kamran Ahmed
75c7e83264 chore: add resource under backend:os-general-knowledge:terminal-usage 2023-06-05 21:14:28 +01:00
Kamran Ahmed
8ca22e0dcc chore: add resource under python:python-testing:pytest 2023-06-05 21:13:27 +01:00
Kamran Ahmed
2b8d1f470c chore: add resource under backend:version-control-systems:git 2023-06-05 21:13:15 +01:00
Kamran Ahmed
c4d9651e95 chore: add resource under react:components 2023-06-05 21:12:13 +01:00
Kamran Ahmed
813c0ebd93 chore: add resource under backend:relational-databases:mysql 2023-06-05 21:11:38 +01:00
Kamran Ahmed
e376942c8d chore: add resource under devops:infrastructure-as-code:gitops:argo-cd 2023-06-05 21:02:21 +01:00
Kamran Ahmed
6d91c11856 chore: add resource link to ux-design >> human-decision-making:frameworks:stephen-wendell-create-action-funnel 2023-06-05 21:00:49 +01:00
Kamran Ahmed
1d47b1fb7b chore: add resource link to cpp >> introduction:what-is-cpp 2023-06-05 20:47:54 +01:00
Kamran Ahmed
54a9e586bf chore: add resource link to frontend >> progressive-web-apps:lighthouse 2023-06-05 20:46:58 +01:00
Kamran Ahmed
b58c2a1356 Fix roadmap json 2023-06-05 20:38:54 +01:00
Kamran Ahmed
dec5e58063 Refactor roadmap and best practice rendering engine 2023-06-05 19:55:58 +01:00
Kamran Ahmed
b0a4130229 Update code review pyramid 2023-06-04 23:00:35 +01:00
Kamran Ahmed
a06e992b8a Update reference link for code review pyramid 2023-06-03 21:31:37 +01:00
Kamran Ahmed
6e1072bea9 Add code review pyramid 2023-06-03 21:17:55 +01:00
Kamran Ahmed
1f9eb18bfb Add update file URL in the topic file 2023-06-02 22:45:37 +01:00
Kamran Ahmed
603bd2b107 Update contribution copy 2023-06-02 21:34:30 +01:00
Kamran Ahmed
0163d9e4f9 Update copy on contribution 2023-06-02 21:33:30 +01:00
Kamran Ahmed
910579f463 Update isNew badges 2023-06-02 21:31:27 +01:00
Kamran Ahmed
d6a28a312a Add contribution functionality 2023-06-02 21:23:26 +01:00
Kamran Ahmed
267a4a7be5 Update cpp roadmap dates 2023-06-01 17:01:39 +01:00
Kamran Ahmed
59111a1a90 Add link to C++ roadmap 2023-06-01 03:59:22 +01:00
Kamran Ahmed
9f5d1aef74 Add content to C++ roadmap 2023-06-01 03:42:42 +01:00
Kamran Ahmed
36eed57ec2 Add content to C++ roadmap 2023-06-01 03:33:27 +01:00
Kamran Ahmed
cc054bb24b Add content to C++ roadmap 2023-06-01 03:18:40 +01:00
Kamran Ahmed
056256015d Add C++ roadmap 2023-06-01 02:55:26 +01:00
Arik Chakma
dd5f3795ec fix: login link (#3985) 2023-05-31 10:00:19 +01:00
Kamran Ahmed
8c29d43bef fix: page loading message does not persist 2023-05-30 19:48:40 +01:00
Kamran Ahmed
aa32258aa1 Refactor page progress implementation 2023-05-30 18:52:41 +01:00
Kamran Ahmed
d2394aca77 Downgrade nanostores 2023-05-30 18:40:58 +01:00
Kamran Ahmed
6804535fe4 chore: update dependencies 2023-05-30 18:27:08 +01:00
github-actions[bot]
3852e7d96f chore: update dependencies to latest (#3973)
Co-authored-by: kamranahmedse <kamranahmedse@users.noreply.github.com>
2023-05-30 17:41:57 +01:00
Kamran Ahmed
eb852caee8 Update link to improve guide 2023-05-30 14:50:37 +01:00
유성현
1414693e33 fix minor typos (#3974)
* 📝 fix : typos

* Update 101-anti-corruption-layer.md

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
2023-05-30 14:32:15 +01:00
Kamran Ahmed
fbdb7e77c3 Fix: active sidebar missing border 2023-05-27 03:04:35 +01:00
Kamran Ahmed
c72658938f Add icons in the mobile sidebar menu 2023-05-26 17:31:10 +01:00
Kamran Ahmed
718c582a8c Update account sidebar 2023-05-26 17:11:59 +01:00
Kamran Ahmed
12f385dffd Show the recently updated item on top 2023-05-26 15:38:04 +01:00
Kamran Ahmed
35f500d218 Show most recently tracked items on top 2023-05-26 03:43:50 +01:00
Kamran Ahmed
44949709d1 Add activity page 2023-05-26 03:16:25 +01:00
Kamran Ahmed
476557db80 Add activity dashboard 2023-05-25 16:37:33 +01:00
Kamran Ahmed
f7625a8250 Add basic UI for activity dashboard 2023-05-24 19:25:35 +01:00
Kamran Ahmed
c06c236da5 Refactor account pages 2023-05-24 15:18:29 +01:00
Kamran Ahmed
24c262282e External redirect tracking from roadmap.sh 2023-05-22 19:59:38 +01:00
Kamran Ahmed
876330522d Add devops by nana link 2023-05-22 19:59:38 +01:00
Sean Kelly
f1c771e95c Fix typo in content (#3959) 2023-05-22 18:08:55 +01:00
Kamran Ahmed
d3668b25e9 Add kbd around cmd+k 2023-05-22 11:06:26 +01:00
Kamran Ahmed
b0493c370c Add prompt engineering roadmap 2023-05-21 13:00:45 +01:00
Arik Chakma
e67caa0ffe chore: firefox input outline (#3951) 2023-05-21 12:56:34 +01:00
Arik Chakma
82a44ddfef fix: user gets logged out on browser quit (#3947) 2023-05-21 12:55:02 +01:00
Arik Chakma
205fe6cc23 fix: firefox bug (#3948) 2023-05-21 12:27:38 +01:00
Kamran Ahmed
591cac8bfa Add content for reliability 2023-05-21 03:39:19 +01:00
Kamran Ahmed
42debdeab0 Add content for real world under prompting techniques 2023-05-21 03:22:20 +01:00
Kamran Ahmed
0555452bf2 Load pages on render 2023-05-21 02:17:31 +01:00
Kamran Ahmed
cc7f9d94bb Clear text on command menu close 2023-05-20 23:47:54 +01:00
Kamran Ahmed
51d986b86f Add support for CMD + K search (#3944)
* Add command k input

* On Enter open the page

* chore: backend fix

* Refactor pages and add retrieval

* Group separation, no result handling and filtering

* Fix responsiveness of command menu

* Activate on CMD+K and focus

* Add icons to menu

* Add page filtering

* Add search icon in navigation

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
2023-05-20 23:20:11 +01:00
Kamran Ahmed
83057d65cd Update content for prompt engineering 2023-05-20 12:15:37 +01:00
Kamran Ahmed
b886f20570 Add pdf for prompt engineering 2023-05-20 05:13:38 +01:00
Kamran Ahmed
dacd2d898b Add prompt enginering roadmap 2023-05-20 05:12:33 +01:00
Kamran Ahmed
a2490efa80 Add content dirs for prompt engineering 2023-05-20 02:37:57 +01:00
Kamran Ahmed
e087b79ade Add prompt engineering roadmap 2023-05-20 02:10:20 +01:00
Kamran Ahmed
10b1a8cb07 Add guide about resources for llms 2023-05-19 16:06:59 +01:00
Kamran Ahmed
f2c06462fa Add assets for ambassador and GraphQL 2023-05-19 01:05:36 +01:00
Kamran Ahmed
ad7ba44a2e Add pages JSON 2023-05-18 20:03:46 +01:00
Kamran Ahmed
7a72c96e79 Remove categroy field injection 2023-05-18 19:36:39 +01:00
Kamran Ahmed
d955044a3b Add category levels to pages 2023-05-18 18:03:08 +01:00
Kamran Ahmed
b86fafd538 Update roadmap note 2023-05-18 16:23:12 +01:00
Abilio Castro
c52a4e6638 Adding link to Spring Boot roadmap from Java roadmap (#3935)
* Adding link to Spring Boot roadmap from Java roadmap

* Adding link to Spring Boot roadmap from Java roadmap

---------

Co-authored-by: Abilio Silva <asilva@descartes.com>
2023-05-18 15:47:11 +01:00
Kamran Ahmed
9d66da6bf9 Add store link in footer 2023-05-18 12:57:20 +01:00
Kamran Ahmed
4b76d0b7aa Rearrange visual 2023-05-16 16:35:18 +01:00
Kamran Ahmed
626026eebc Add guide about LLMs 2023-05-16 16:35:18 +01:00
Kamran Ahmed
fdd12acb8e Add link to full stack roadmap 2023-05-15 19:51:14 +01:00
Kamran Ahmed
02015826ff Update related roadmaps 2023-05-15 14:51:45 +01:00
Kamran Ahmed
5d07ce32d8 Change color for skipped 2023-05-14 14:57:51 +01:00
Mohammad Ostadi
3967b16d25 Fix wrong backend and devops links (#3919) 2023-05-14 13:09:38 +01:00
Kamran Ahmed
f325183691 Add keydowns in updating progerss 2023-05-14 03:31:00 +01:00
Kamran Ahmed
a029850531 Change color of skipped 2023-05-14 03:12:28 +01:00
Kamran Ahmed
d3d2ae5889 Refactor update topic progress functionality 2023-05-14 03:05:54 +01:00
Kamran Ahmed
4a049b2a7a Skip colors 2023-05-14 02:53:07 +01:00
Kamran Ahmed
fd349f2da8 Allow skipping 2023-05-14 02:41:04 +01:00
Kamran Ahmed
f338bd5ecb Refactor progress button 2023-05-13 12:44:14 +01:00
Kamran Ahmed
a3470cd844 Fix flickering issue on the profile pages 2023-05-12 22:38:14 +01:00
Kamran Ahmed
f4635d794f Refactor buttons 2023-05-12 17:26:37 +01:00
Kamran Ahmed
426fe44dc8 Add content for full stack roadmap 2023-05-12 14:14:45 +01:00
Kamran Ahmed
4ed39cec1a Update monitoring 2023-05-12 13:07:46 +01:00
Kamran Ahmed
b1f0844546 Add github action sample 2023-05-12 12:57:35 +01:00
Kamran Ahmed
88aa7e4024 Add fullstack roadmap 2023-05-12 12:51:34 +01:00
Kamran Ahmed
471f6348f1 Add isNew flag to fullstack roadmap 2023-05-12 12:51:34 +01:00
Kamran Ahmed
9dfb70c941 Add fullstack roadmap 2023-05-12 12:51:34 +01:00
Zai Santillan
6fa7e0d1c0 Update twitter username (#3907) 2023-05-11 03:41:20 +01:00
Kamran Ahmed
5ccfa654ec Add support for custom labels in ga 2023-05-10 01:18:23 +01:00
Kamran Ahmed
1c67068eab Update twitter username 2023-05-09 15:39:50 +01:00
Kamran Ahmed
f5ff2a0823 Add contribution url to topic detail popup 2023-05-09 15:28:16 +01:00
Aman Tank
58503f67f3 Fix #3882, resolves #3874 (#3882)
* F[ixed] Link in Content Delivery Networks #3881

* [Fixed] Typo #3881

* Delete package-lock.json

---------

Co-authored-by: Aman Tank <132202130+amanntank@users.noreply.github.com>
2023-05-09 15:24:21 +01:00
Kamran Ahmed
5dd0479caf Add feature image 2023-05-09 11:47:07 +01:00
Kamran Ahmed
7441f1a203 Refactor avatar implementation 2023-05-09 03:36:29 +01:00
Arik Chakma
4d3ebb0ac6 chore: placeholder image 2023-05-09 03:36:29 +01:00
Arik Chakma
47d5716238 feat: upload profile picture 2023-05-09 03:36:29 +01:00
Kamran Ahmed
94d888a61e Add avatar url config 2023-05-09 03:35:45 +01:00
bany
ddd43a1514 Add missing file android.pdf (#3899) 2023-05-09 02:22:52 +01:00
Kamran Ahmed
2cf94f981b Resource progress functionality 2023-05-09 02:14:27 +01:00
Kamran Ahmed
f1973f63c2 Rename "Mark as Done" and "Mark as Pending" 2023-05-09 01:53:18 +01:00
Arik Chakma
dfb67e17d5 chore: in progress design 2023-05-09 06:21:39 +06:00
Arik Chakma
48239772f6 fix: removing classes 2023-05-09 06:04:47 +06:00
Arik Chakma
1cea9d0e13 chore: added pending state for topics 2023-05-09 06:02:41 +06:00
Kamran Ahmed
6591c36ef4 Add visit tracker to roadmap 2023-05-08 23:06:09 +01:00
Kamran Ahmed
41de9c47b0 Add partner images 2023-05-08 22:48:45 +01:00
Kamran Ahmed
0ba1a8a1d1 Rearrange scripts 2023-05-08 22:16:27 +01:00
Kamran Ahmed
6fcb153244 Fix invalid markdown language warnings 2023-05-08 21:12:34 +01:00
Kamran Ahmed
7a8d97b1cd Refactor analytics 2023-05-08 21:06:33 +01:00
Kamran Ahmed
9e37076d0d Add preconnect for ga and api 2023-05-08 20:59:57 +01:00
Kamran Ahmed
f8e5661353 Refactor perf issues 2023-05-08 20:54:56 +01:00
Kamran Ahmed
4d4cda6cac Fix accessibility issues 2023-05-08 20:37:26 +01:00
Arik Chakma
8b528f39f2 fix: broken link (#3897) 2023-05-08 16:58:35 +01:00
Kamran Ahmed
e1a04b4a20 Update username 2023-05-08 14:36:21 +01:00
Kamran Ahmed
f0e8ffe565 Fix spelling mistake 2023-05-06 16:42:12 +01:00
Kamran Ahmed
f9c1e64235 Add docker roadmap to readme 2023-05-06 01:44:25 +01:00
Kamran Ahmed
0174c9156b Fix formatting issue 2023-05-05 22:51:38 +01:00
Kamran Ahmed
2ee81e6ff3 Add docker roadmap 2023-05-05 22:49:57 +01:00
Kamran Ahmed
42ab5a3e9e Add docker roadmpa 2023-05-05 22:49:57 +01:00
Kamran Ahmed
e9fa663410 Notify icon 2023-05-05 12:16:07 +01:00
Kamran Ahmed
2d17a267be Refactor upcoming form 2023-05-04 18:18:44 +01:00
Arik Chakma
40371cdded fix: notify for upcoming page (#3887)
* chore: upcoming login

* fix: subscribed button
2023-05-04 18:09:32 +01:00
Arik Chakma
6bb315a2fc fix: featured items heading position (#3837) 2023-05-03 14:48:21 +01:00
Kamran Ahmed
fc2c9a1439 Remove sponsors and relevant codebase 2023-05-03 12:50:15 +01:00
Kamran Ahmed
b50935ecd6 Remove delay for sponsors 2023-05-03 04:09:09 +01:00
Kamran Ahmed
9b73d60c5d Add support for multiple ads 2023-05-03 03:14:04 +01:00
Kamran Ahmed
504ee8cf5e Add support for multiple ads 2023-05-02 20:48:01 +01:00
Kamran Ahmed
057bbddd9f Add support for multiple ads 2023-05-02 20:48:01 +01:00
Joshua
4063979c2a Fix typo (#3797) 2023-05-02 20:34:17 +01:00
Rita Bradley
da391fe9ed Fix typos (#3795)
* Fix typos

- Add 's' to reason
- Change "it's" to is
- Remove unnecessary hyphen

* Fix typos

- change 'remove' to 'removing'
- change comma to semicolon

* Fix typos

- change "don't" to "doesn't"
- add "is" before "relied upon"
2023-05-02 20:33:33 +01:00
Kamran Ahmed
953ca9257c Fix typo on homepage 2023-05-01 17:02:16 +01:00
Kamran Ahmed
396bedd319 Update sponsor banners 2023-05-01 16:50:59 +01:00
The New Stack
e05269f117 Add TypeScript installation guide (#3791)
* Added 1 TNS Article

* Update src/data/roadmaps/typescript/content/100-typescript/102-install-configure/index.md

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-04-27 13:58:30 +01:00
Kamran Ahmed
77c7ca8835 Update badges 2023-04-27 12:41:08 +01:00
Kamran Ahmed
e877f5c610 Add code review best practices 2023-04-27 12:39:16 +01:00
Kamran Ahmed
42e1a79697 Update code review pdf 2023-04-26 21:12:09 +01:00
Kamran Ahmed
ce32cdc8a4 Update code review texts 2023-04-26 20:54:29 +01:00
Kamran Ahmed
e2a0bd23c0 Add best practice content 2023-04-26 17:44:02 +01:00
Kamran Ahmed
98f0ebde8b Add code-review best practices 2023-04-26 17:17:39 +01:00
Kamran Ahmed
bc018f8b39 Add code review best practices 2023-04-26 15:57:32 +01:00
Kamran Ahmed
03bd478aaa Add data aggregation command 2023-04-26 13:47:19 +01:00
Kamran Ahmed
67a8582c22 Fix overlapping login and videos 2023-04-24 18:07:51 +01:00
Kamran Ahmed
7533575df9 Update PgQ (#3856) 2023-04-24 15:29:21 +01:00
Kamran Ahmed
34fcd74d93 Update ad text 2023-04-21 20:27:52 +01:00
Kamran Ahmed
1558feb734 Update liblab link 2023-04-21 19:48:38 +01:00
Kamran Ahmed
bc4d9f9e2f Add postgresql dba pdf 2023-04-19 22:11:00 +01:00
Kamran Ahmed
4142c7b51e Compress postgresql json 2023-04-19 21:55:56 +01:00
Kamran Ahmed
e36a749223 Update postgresql roadmap 2023-04-19 21:55:22 +01:00
Kamran Ahmed
e69d9b4238 Add PEV2 2023-04-19 15:28:14 +01:00
Kamran Ahmed
3132a39816 Fix sponsor opener 2023-04-19 00:57:41 +01:00
Kamran Ahmed
03f9fa51ff Update roadmap node 2023-04-18 14:36:54 +01:00
Kamran Ahmed
e2062aefe9 Update postgresql roadmap 2023-04-18 14:24:58 +01:00
Kamran Ahmed
855ba7bbfb Add postgresql roadmap 2023-04-18 14:24:58 +01:00
Kamran Ahmed
ad71b6398d Fix styling issue 2023-04-18 14:24:57 +01:00
Kamran Ahmed
0ea0629104 Add postgresql-dba content 2023-04-18 14:24:57 +01:00
Kamran Ahmed
8b2f12fcdd Improve the content writing performance 2023-04-18 14:24:57 +01:00
Kamran Ahmed
e66bff74bf Add postgresql-dba content 2023-04-18 14:24:57 +01:00
github-actions[bot]
58ea34bb49 chore: update sponsors (#3821)
Co-authored-by: kamranahmedse <kamranahmedse@users.noreply.github.com>
2023-04-17 15:01:32 +01:00
Kamran Ahmed
275c2c3c88 Add noindex pages 2023-04-17 00:57:20 +01:00
Kamran Ahmed
f13c29adad Enable github auth 2023-04-14 21:17:49 +01:00
Kamran Ahmed
ec9f836a1f Fix check icon 2023-04-14 20:56:24 +01:00
Kamran Ahmed
589d157be5 Disable github login for now 2023-04-14 20:16:38 +01:00
Kamran Ahmed
a2719bc771 feat: user accounts functionality (#3813)
* feat: integrate astro

* chore: login popup design

* chore: data-popup changed

* refactor: github and google button

* chore: signup page

* chore: login popup design

* chore: signup page design

* chore: auth divider

* feat: integrate astro

* chore: login popup design

* chore: data-popup changed

* refactor: github and google button

* chore: signup page

* chore: login popup design

* chore: signup page design

* chore: auth divider

* chore: login feature

* chore: login error message

* chore: added name in token decode return

* chore: use auth hook

* chore: logout vs login

* chore: download button link

* chore: account dropdown

* fix: dropdown z index

* chore: profile page

* Add missing content for backend roadmap

* Remove unused styles

* Add login with google

* chore: google login implementation

* chore: profile guard clause

* fix: button size

* chore: preact to astro components

* chore: preact to astro comp

* chore: github astro component

* chore: google login error handling

* chore: github login error handling

* chore: change password page

* chore: rename profile to password

* fix: change password rename

* chore: update profile page

* chore: setting sidebar

* fix: setting dropdown design

* chore: required indicator

* chore: change password form

* chore: update profile form

* chore: mobile navigation

* fix: form data empty error

* chore: email login and signup components

* chore: forgot password page

* chore: reset password page

* chore: verify account page

* chore: resend verification email

* fix: types in spinner

* chore: forgot password functionality

* fix: class -> className

* chore: reset password page

* chore: reset password functionality

* chore: login page

* fix: spacing for login and signup page

* refactor: email login form

* chore: astro spinner

* chore: pre-fill user data

* chore: dummy placeholder

* chore: forgot password link add

* fix: replaced constants

* chore: forgot password link

* chore: change password for social provider

* chore: internal pages guard

* chore: internal paths

* refactor: change password errors

* refactor: update profile errors

* chore: mark as done overlay

* fix: uncontrolled to controlled form

* fix: de-structure error

* chore: error messages

* fix: 401 error code redirect to login page

* chore: loading spinner accessibilities

* fix: remove spinner

* chore: keep spinner after success to redirect

* chore: keep the spinner

* style: resend email underline

* chore: chevron down account

* chore: roadmap pdf link download

* chore: roadmap pdf link download

* chore: best practices buttons

* fix: verify account text

* fix: topic overlay hide

* chore: base verify design

* chore: email verify page

* fix: div tag missing

* Formatting

* Refactor top navigation

* Prettier

* Update dependencies

* Refactor top navigation

* Refactor login button

* Remove captcha and add google scripts

* Refactor email sign up form

* Resend verfication email functionality

* Refactor verification pending page

* Add verify account functionality

* Update signup text

* Add login page

* Add login button in top nav

* Email login form

* Handle authenticatoin

* Show hide auth elements change

* Add ease-in on the guest elements

* Refactor logic for download and subscribe popups

* Add forgot password

* Rename fetch lib

* Add authentication popup

* Refactor logic for mark done and pending

* Handle logout

* Add route protection

* Popup opener to close the overlay

* Remember page when logging in

* Add reset password page

* Change placement of constant

* Update profile page

* Add update password form

* Update password page

* Update profile page

* Update design

* chore: toggle mark resource done api

* chore: toggle topic done

* chore: get user resource progress api

* fix: best practice topic toggle

* chore: fetch progress

* fix: query selector for topics

* Keep track of the old page before social login

* Update public api url

* Add user progress tracking

* Update topic done functionality

* Add progress loader

* Add page wide spinner

* Add spinner on setting pages

* Add fingerprint to user requests

* Use http wrapper instead of fetch

* Update fingerprint

* Minor improvements

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
2023-04-14 19:22:52 +01:00
Umair Raza
c5645299aa Issues: Text not showing up properly #3796 (#3802) 2023-04-12 12:09:25 +01:00
Kamran Ahmed
6aac3f296c Remove youtube banner from top 2023-04-08 13:57:32 +01:00
Haseeb Ansari
137635f11a fix: wrong MAN explanation (#3771)
Co-authored-by: haseeb.ansari <haseeb.ansari@qordata.com>
2023-04-07 18:55:53 +01:00
karthikeyantc
8487d2f443 Updated the Route Guard for Angular (#3731)
* Update 104-guards.md

Added the link for resolve route guard

* Update 103-router-events.md

Added the documentation for Router events.

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-04-05 17:35:19 +01:00
github-actions[bot]
a7bee1fea7 chore: update dependencies to latest (#3744)
Co-authored-by: kamranahmedse <kamranahmedse@users.noreply.github.com>
2023-04-05 17:34:13 +01:00
CMarghin
43292de507 Fix typo, replace Interface with Inference (#3750) 2023-04-05 17:32:51 +01:00
Sanjay Singha
bee30defb5 Add content for FVM (#3759)
* Adding content for FVM

* Update src/data/roadmaps/flutter/content/101-setup-development-environment/102-fvm.md

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-04-05 17:32:20 +01:00
Pedro Macedo
52649a2d3c Change react docs link (#3742) 2023-04-05 17:31:04 +01:00
Salah Eddine Mebkhouti
be47ac6573 Update invalid link (#3752)
Co-authored-by: AIpills <x9one@pop-os.localdomain>
2023-04-04 14:09:15 +01:00
Arik Chakma
24ce27090e Fix guide type (#3754) 2023-04-04 14:08:50 +01:00
Ehren Nwokocha
8dd0225720 Fix minor typo (#3756)
I replaced and with it in the Javascript versioning paragraph
2023-04-04 14:08:30 +01:00
1117 changed files with 63329 additions and 6664 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
PUBLIC_API_URL=http://api.roadmap.sh
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars

View File

@@ -3,6 +3,8 @@ on:
push:
branches: [ master ]
env:
PUBLIC_API_URL: "https://api.roadmap.sh"
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PAT: ${{ secrets.PAT }}
CI: true

View File

@@ -1,43 +0,0 @@
name: Update Sponsors
on:
workflow_dispatch: # allow manual run
schedule:
- cron: '0 0 * * *' # run daily at 00:00 UTC
env:
SPONSOR_SHEET_API_KEY: ${{ secrets.SPONSOR_SHEET_API_KEY }}
SPONSOR_SHEET_ID: ${{ secrets.SPONSOR_SHEET_ID }}
jobs:
update-sponsors:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v3
with:
node-version: 18
- uses: pnpm/action-setup@v2.2.2
with:
version: 7.13.4
- name: Install dependencies
run: |
pnpm install
- name: Update sponsors
run: |
node bin/update-sponsors.cjs
- name: Create PR
uses: peter-evans/create-pull-request@v4
with:
delete-branch: false
branch: 'update-sponsors'
base: 'master'
labels: |
sponsors
automated pr
reviewers: kamranahmedse
commit-message: 'chore: update sponsors'
title: 'Update Sponsor Banners'
body: |
Updates sponsor banners.
Please review the changes and merge if everything looks good.

5
.gitignore vendored
View File

@@ -5,7 +5,7 @@ dist/
# dependencies
node_modules/
bin/developer-roadmap
scripts/developer-roadmap
# logs
npm-debug.log*
@@ -23,4 +23,5 @@ pnpm-debug.log*
/test-results/
/playwright-report/
/playwright/.cache/
tests-examples
tests-examples
*.csv

View File

@@ -1,4 +1,5 @@
// https://astro.build/config
import preact from '@astrojs/preact';
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import compress from 'astro-compress';
@@ -6,6 +7,7 @@ import { defineConfig } from 'astro/config';
import rehypeExternalLinks from 'rehype-external-links';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
// https://astro.build/config
export default defineConfig({
site: 'https://roadmap.sh/',
markdown: {
@@ -56,5 +58,6 @@ export default defineConfig({
css: false,
js: false,
}),
preact(),
],
});

View File

@@ -1,19 +0,0 @@
const fs = require('node:fs');
const path = require('node:path');
const jsonsDir = path.join(process.cwd(), 'public/jsons');
const childJsonDirs = fs.readdirSync(jsonsDir);
childJsonDirs.forEach((childJsonDir) => {
const fullChildJsonDirPath = path.join(jsonsDir, childJsonDir);
const jsonFiles = fs.readdirSync(fullChildJsonDirPath);
jsonFiles.forEach((jsonFileName) => {
console.log(`Compressing ${jsonFileName}...`);
const jsonFilePath = path.join(fullChildJsonDirPath, jsonFileName);
const json = require(jsonFilePath);
fs.writeFileSync(jsonFilePath, JSON.stringify(json));
});
});

View File

@@ -11,34 +11,44 @@
"format": "prettier --write .",
"astro": "astro",
"deploy": "NODE_DEBUG=gh-pages gh-pages -d dist -t",
"compress:jsons": "node bin/compress-jsons.cjs",
"compress:jsons": "node scripts/compress-jsons.cjs",
"upgrade": "ncu -u",
"roadmap-links": "node bin/roadmap-links.cjs",
"roadmap-dirs": "node bin/roadmap-dirs.cjs",
"roadmap-content": "node bin/roadmap-content.cjs",
"best-practice-dirs": "node bin/best-practice-dirs.cjs",
"roadmap-links": "node scripts/roadmap-links.cjs",
"roadmap-dirs": "node scripts/roadmap-dirs.cjs",
"roadmap-content": "node scripts/roadmap-content.cjs",
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
"best-practice-content": "node scripts/best-practice-content.cjs",
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/sitemap": "^1.2.1",
"@astrojs/tailwind": "^3.1.1",
"astro": "^2.1.7",
"astro-compress": "^1.1.35",
"@astrojs/preact": "^2.2.1",
"@astrojs/sitemap": "^1.3.3",
"@astrojs/tailwind": "^3.1.3",
"@fingerprintjs/fingerprintjs": "^3.4.1",
"@nanostores/preact": "^0.5.0",
"astro": "^2.5.7",
"astro-compress": "^1.1.46",
"jose": "^4.14.4",
"js-cookie": "^3.0.5",
"nanostores": "^0.9.1",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.8.0",
"rehype-external-links": "^2.0.1",
"roadmap-renderer": "^1.0.4",
"tailwindcss": "^3.2.7"
"npm-check-updates": "^16.10.12",
"preact": "^10.15.1",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@playwright/test": "^1.32.1",
"@playwright/test": "^1.34.3",
"@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",
"prettier": "^2.8.7",
"prettier-plugin-astro": "^0.8.0",
"prettier-plugin-tailwindcss": "^0.2.6"
"prettier": "^2.8.8",
"prettier-plugin-astro": "^0.10.0",
"prettier-plugin-tailwindcss": "^0.3.0"
}
}

6669
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 36 36">
<path fill="#000" d="M22.25 4h-8.5a1 1 0 0 0-.96.73l-5.54 19.4a.5.5 0 0 0 .62.62l5.05-1.44a2 2 0 0 0 1.38-1.4l3.22-11.66a.5.5 0 0 1 .96 0l3.22 11.67a2 2 0 0 0 1.38 1.39l5.05 1.44a.5.5 0 0 0 .62-.62l-5.54-19.4a1 1 0 0 0-.96-.73Z"/>
<path fill="url(#gradient)" d="M18 28a7.63 7.63 0 0 1-5-2c-1.4 2.1-.35 4.35.6 5.55.14.17.41.07.47-.15.44-1.8 2.93-1.22 2.93.6 0 2.28.87 3.4 1.72 3.81.34.16.59-.2.49-.56-.31-1.05-.29-2.46 1.29-3.25 3-1.5 3.17-4.83 2.5-6-.67.67-2.6 2-5 2Z"/>
<defs>
<linearGradient id="gradient" x1="16" x2="16" y1="32" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#000"/>
<stop offset="1" stop-color="#000" stop-opacity="0"/>
</linearGradient>
</defs>
<style>
@media (prefers-color-scheme:dark){:root{filter:invert(100%)}}
</style>
</svg>

Before

Width:  |  Height:  |  Size: 873 B

BIN
public/guides/llms.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/roadmaps/cpp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

BIN
public/roadmaps/docker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -4,16 +4,16 @@
<p align="center">Community driven roadmaps, articles and resources for developers<p>
<p align="center">
<a href="https://roadmap.sh/roadmaps">
<img src="https://img.shields.io/badge/-Roadmaps%20-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
<img src="https://img.shields.io/badge/%E2%9C%A8-Roadmaps%20-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
</a>
<a href="https://roadmap.sh/best-practices">
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
</a>
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
<img src="https://img.shields.io/badge/-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
</a>
<a href="https://github.com/kamranahmedse/developer-roadmap/tree/0471d44c8fae58b6a36a7c57bba12253916d0249/translations">
<img src="https://img.shields.io/badge/-Translations-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
</a>
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
<img src="https://img.shields.io/badge/%E2%9D%A4-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
</a>
</p>
</p>
@@ -33,12 +33,14 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Frontend Roadmap](https://roadmap.sh/frontend)
- [Backend Roadmap](https://roadmap.sh/backend)
- [DevOps Roadmap](https://roadmap.sh/devops)
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
- [QA Roadmap](https://roadmap.sh/qa)
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
- [JavaScript Roadmap](https://roadmap.sh/javascript)
- [TypeScript Roadmap](https://roadmap.sh/typescript)
- [C++ Roadmap](https://roadmap.sh/cpp)
- [React Roadmap](https://roadmap.sh/react)
- [Vue Roadmap](https://roadmap.sh/vue)
- [Angular Roadmap](https://roadmap.sh/angular)
@@ -59,9 +61,12 @@ Here is the list of available roadmaps with more being actively worked upon.
- [Cyber Security Roadmap](https://roadmap.sh/cyber-security)
- [MongoDB Roadmap](https://roadmap.sh/mongodb)
- [UX Design Roadmap](https://roadmap.sh/ux-design)
- [Docker Roadmap](https://roadmap.sh/docker)
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
We have also added a new form of visual content covering best practices:
- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review)
- [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance)
- [API Security Best Practices](https://roadmap.sh/best-practices/api-security)
- [AWS Best Practices](https://roadmap.sh/best-practices/aws)

View File

@@ -0,0 +1,173 @@
const fs = require('fs');
const path = require('path');
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
const ALL_BEST_PRACTICES_DIR = path.join(
__dirname,
'../src/data/best-practices'
);
const BEST_PRACTICE_JSON_DIR = path.join(
__dirname,
'../public/jsons/best-practices'
);
const bestPracticeId = process.argv[2];
const bestPracticeTitle = bestPracticeId.replace(/-/g, ' ');
const allowedBestPracticeIds = fs.readdirSync(ALL_BEST_PRACTICES_DIR);
if (!bestPracticeId) {
console.error('bestPracticeId is required');
process.exit(1);
}
if (!allowedBestPracticeIds.includes(bestPracticeId)) {
console.error(`Invalid bestPractice key ${bestPracticeId}`);
console.error(`Allowed keys are ${allowedBestPracticeIds.join(', ')}`);
process.exit(1);
}
const BEST_PRACTICE_CONTENT_DIR = path.join(
ALL_BEST_PRACTICES_DIR,
bestPracticeId,
'content'
);
const { Configuration, OpenAIApi } = require('openai');
const configuration = new Configuration({
apiKey: OPEN_AI_API_KEY,
});
const openai = new OpenAIApi(configuration);
function getFilesInFolder(folderPath, fileList = {}) {
const files = fs.readdirSync(folderPath);
files.forEach((file) => {
const filePath = path.join(folderPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
getFilesInFolder(filePath, fileList);
} else if (stats.isFile()) {
const fileUrl = filePath
.replace(BEST_PRACTICE_CONTENT_DIR, '') // Remove the content folder
.replace(/\/\d+-/g, '/') // Remove ordering info `/101-ecosystem`
.replace(/\/index\.md$/, '') // Make the `/index.md` to become the parent folder only
.replace(/\.md$/, ''); // Remove `.md` from the end of file
fileList[fileUrl] = filePath;
}
});
return fileList;
}
function writeTopicContent(topicTitle) {
let prompt = `I am reading a guide that has best practices about "${bestPracticeTitle}". I want to know more about "${topicTitle}". Write me a brief introductory paragraph about this and some tips on how I make sure of this? Behave as if you are the author of the guide.`;
console.log(`Generating '${topicTitle}'...`);
return new Promise((resolve, reject) => {
openai
.createChatCompletion({
model: 'gpt-4',
messages: [
{
role: 'user',
content: prompt,
},
],
})
.then((response) => {
const article = response.data.choices[0].message.content;
resolve(article);
})
.catch((err) => {
reject(err);
});
});
}
async function writeFileForGroup(group, topicUrlToPathMapping) {
const topicId = group?.properties?.controlName;
const topicTitle = group?.children?.controls?.control?.find(
(control) => control?.typeID === 'Label'
)?.properties?.text;
const currTopicUrl = `/${topicId}`;
if (currTopicUrl.startsWith('/check:')) {
return;
}
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
if (!contentFilePath) {
console.log(`Missing file for: ${currTopicUrl}`);
process.exit(0);
return;
}
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
if (!isFileEmpty) {
console.log(`Ignoring ${topicId}. Not empty.`);
return;
}
let newFileContent = `# ${topicTitle}`;
if (!OPEN_AI_API_KEY) {
console.log(`Writing ${topicId}..`);
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
return;
}
const topicContent = await writeTopicContent(topicTitle);
newFileContent += `\n\n${topicContent}`;
console.log(`Writing ${topicId}..`);
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
// console.log(currentFileContent);
// console.log(currTopicUrl);
// console.log(topicTitle);
// console.log(topicUrlToPathMapping[currTopicUrl]);
}
async function run() {
const topicUrlToPathMapping = getFilesInFolder(BEST_PRACTICE_CONTENT_DIR);
const bestPracticeJson = require(path.join(
BEST_PRACTICE_JSON_DIR,
`${bestPracticeId}.json`
));
const groups = bestPracticeJson?.mockup?.controls?.control?.filter(
(control) =>
control.typeID === '__group__' &&
!control.properties?.controlName?.startsWith('ext_link')
);
if (!OPEN_AI_API_KEY) {
console.log('----------------------------------------');
console.log('OPEN_AI_API_KEY not found. Skipping openai api calls...');
console.log('----------------------------------------');
}
const writePromises = [];
for (let group of groups) {
writePromises.push(writeFileForGroup(group, topicUrlToPathMapping));
}
console.log('Waiting for all files to be written...');
await Promise.all(writePromises);
}
run()
.then(() => {
console.log('Done');
})
.catch((err) => {
console.error(err);
process.exit(1);
});

129
scripts/page-data-agg.cjs Normal file
View File

@@ -0,0 +1,129 @@
const csv = require('csv-parser');
const fs = require('fs');
const path = require('path');
const csvFilePath = path.join(__dirname, '../data.csv');
const results = {};
const pageSummary = {};
fs.createReadStream(csvFilePath)
.pipe(
csv({
separator: ',',
mapHeaders: ({ header, index }) =>
header.toLowerCase().replace(/ /g, '_'),
mapValues: ({ header, index, value }) => {
if (header === 'page') {
return (
value
.replace(/"/g, '')
.replace(/'/g, '')
.replace(/`/g, '')
.replace(/\?r=/g, '#r#')
.replace(/\?.+?$/g, '')
.replace(/#r#/g, '?r=')
.replace(/\/$/g, '') || '/'
);
}
if (header !== 'month_of_year') {
return parseInt(value, 10);
}
return value;
},
})
)
.on('data', (data) => {
const { page, month_of_year, unique_pageviews, users } = data;
const pageData = results[page] || {};
const existingPageMonthData = pageData[month_of_year] || {};
const existingViews = existingPageMonthData.views || 0;
const existingUsers = existingPageMonthData.users || 0;
const newViews = existingViews + unique_pageviews;
const newUsers = existingUsers + users;
pageData[month_of_year] = {
views: newViews,
users: newUsers,
};
results[page] = pageData;
pageSummary[page] = pageSummary[page] || { views: 0, users: 0 };
pageSummary[page].views += unique_pageviews;
pageSummary[page].users += users;
})
.on('end', () => {
const csvHeader = [
'Page',
'Jan 2022',
'Feb 2022',
'Mar 2022',
'Apr 2022',
'May 2022',
'Jun 2022',
'Jul 2022',
'Aug 2022',
'Sep 2022',
'Oct 2022',
'Nov 2022',
'Dec 2022',
'Jan 2023',
'Feb 2023',
'Mar 2023',
'Apr 2023',
'May 2023',
'Jun 2023',
'Jul 2023',
'Aug 2023',
'Sep 2023',
'Oct 2023',
'Nov 2023',
'Dec 2023',
];
const csvRows = Object.keys(pageSummary)
.filter(pageUrl => pageSummary[pageUrl].views > 10)
.filter(pageUrl => !['/upcoming', '/pdfs', '/signup', '/login', '/@'].includes(pageUrl))
.sort((pageA, pageB) => {
const aViews = pageSummary[pageA].views;
const bViews = pageSummary[pageB].views;
return bViews - aViews;
})
.map((pageUrl) => {
const rawPageResult = results[pageUrl];
const pageResultCsvRow = [];
csvHeader.forEach((csvHeaderItem) => {
if (csvHeaderItem === 'Page') {
pageResultCsvRow.push(pageUrl);
return;
}
const csvHeaderItemAlt = csvHeaderItem
.replace(/ /g, '_')
.toLowerCase();
const result = rawPageResult[csvHeaderItem || csvHeaderItemAlt] || {};
const views = result.views || 0;
const users = result.users || 0;
pageResultCsvRow.push(users);
});
return pageResultCsvRow;
});
const finalCsvRows = [csvHeader, ...csvRows];
const csvRowStrings = finalCsvRows.map((row) => {
return row.join(',');
});
const csvString = csvRowStrings.join('\n');
fs.writeFileSync(path.join(__dirname, '../data-agg.csv'), csvString);
});

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];
@@ -61,12 +60,12 @@ function writeTopicContent(currTopicUrl) {
const roadmapTitle = roadmapId.replace(/-/g, ' ');
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
if (!childTopic) {
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
}
console.log(`Genearting '${childTopic || parentTopic}'...`);
console.log(`Generating '${childTopic || parentTopic}'...`);
return new Promise((resolve, reject) => {
openai
@@ -90,10 +89,60 @@ function writeTopicContent(currTopicUrl) {
});
}
async function writeFileForGroup(group, topicUrlToPathMapping) {
const topicId = group?.properties?.controlName;
const topicTitle = group?.children?.controls?.control?.find(
(control) => control?.typeID === 'Label'
)?.properties?.text;
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
if (!currTopicUrl) {
return;
}
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
if (!contentFilePath) {
console.log(`Missing file for: ${currTopicUrl}`);
return;
}
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
if (!isFileEmpty) {
console.log(`Ignoring ${topicId}. Not empty.`);
return;
}
let newFileContent = `# ${topicTitle}`;
if (!OPEN_AI_API_KEY) {
console.log(`Writing ${topicId}..`);
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
return;
}
const topicContent = await writeTopicContent(currTopicUrl);
newFileContent += `\n\n${topicContent}`;
console.log(`Writing ${topicId}..`);
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
// console.log(currentFileContent);
// console.log(currTopicUrl);
// console.log(topicTitle);
// console.log(topicUrlToPathMapping[currTopicUrl]);
}
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__' &&
@@ -106,50 +155,13 @@ async function run() {
console.log('----------------------------------------');
}
const writePromises = [];
for (let group of groups) {
const topicId = group?.properties?.controlName;
const topicTitle = group?.children?.controls?.control?.find(
(control) => control?.typeID === 'Label'
)?.properties?.text;
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
if (!currTopicUrl) {
continue;
}
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
if (!contentFilePath) {
console.log(`Missing file for: ${currTopicUrl}`);
return;
}
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
if (!isFileEmpty) {
console.log(`Ignoring ${topicId}. Not empty.`);
continue;
}
let newFileContent = `# ${topicTitle}`;
if (!OPEN_AI_API_KEY) {
console.log(`Writing ${topicId}..`);
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
continue;
}
const topicContent = await writeTopicContent(currTopicUrl);
newFileContent += `\n\n${topicContent}`;
console.log(`Writing ${topicId}..`);
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
// console.log(currentFileContent);
// console.log(currTopicUrl);
// console.log(topicTitle);
// console.log(topicUrlToPathMapping[currTopicUrl]);
writePromises.push(writeFileForGroup(group, topicUrlToPathMapping));
}
console.log('Waiting for all files to be written...');
await Promise.all(writePromises);
}
run()

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

@@ -22,7 +22,7 @@ function removeAllSponsors(baseContentDir) {
const contentDir = fs.readdirSync(contentDirPath);
contentDir.forEach((content) => {
console.log('Removing sponsor from: ', content);
console.log('Removing sponsors from: ', content);
const pageFilePath = path.join(contentDirPath, content, `${content}.md`);
const pageFileContent = fs.readFileSync(pageFilePath, 'utf8');
@@ -35,7 +35,7 @@ function removeAllSponsors(baseContentDir) {
.trim();
let frontmatterObj = yaml.load(existingFrontmatter);
delete frontmatterObj.sponsor;
delete frontmatterObj.sponsors;
const newFrontmatter = yaml.dump(frontmatterObj, {
lineWidth: 10000,
@@ -87,27 +87,23 @@ function addPageSponsor({
.trim();
let frontmatterObj = yaml.load(existingFrontmatter);
delete frontmatterObj.sponsor;
const sponsors = frontmatterObj.sponsors || [];
const frontmatterValues = Object.entries(frontmatterObj);
const roadmapLabel = frontmatterObj.briefTitle;
sponsors.push({
url: redirectUrl,
title: adTitle,
imageUrl,
description: adDescription,
page: roadmapLabel,
company,
});
// Insert sponsor data at 10 index i.e. after
// roadmap dimensions in the frontmatter
frontmatterValues.splice(10, 0, [
'sponsor',
{
url: redirectUrl,
title: adTitle,
imageUrl,
description: adDescription,
event: {
category: 'SponsorClick',
action: `${company} Redirect`,
label: `${roadmapLabel} / ${company} Link`,
},
},
]);
frontmatterValues.splice(10, 0, ['sponsors', sponsors]);
frontmatterObj = Object.fromEntries(frontmatterValues);

View File

@@ -0,0 +1,115 @@
---
import AstroIcon from './AstroIcon.astro';
const { activePageId, activePageTitle } = Astro.props;
export interface Props {
activePageId: string;
activePageTitle: string;
}
const sidebarLinks = [
{
href: '/account',
title: 'Activity',
id: 'activity',
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/update-password',
title: 'Security',
id: 'change-password',
icon: {
glyph: 'security',
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'
id='settings-menu'
>
{activePageTitle}
<AstroIcon icon='dropdown' />
</button>
<ul
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'
>
{
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`} />
{sidebarLink.title}
</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>
<!-- /End Desktop Sidebar -->
<div class='grow px-0 py-0 md:px-10 md:py-10'>
<slot />
</div>
</div>
<script>
const menuButton = document.getElementById('settings-menu');
const menuDropdown = document.getElementById('settings-menu-dropdown');
menuButton?.addEventListener('click', () => {
menuDropdown?.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!menuButton?.contains(e.target as Node)) {
menuDropdown?.classList.add('hidden');
}
});
</script>

View File

@@ -0,0 +1,56 @@
type ActivityCountersType = {
done: {
today: number;
total: number;
};
learning: {
today: number;
total: number;
};
streak: {
count: number;
};
};
type ActivityCounterType = {
text: string;
count: string;
};
function ActivityCounter(props: ActivityCounterType) {
const { text, count } = props;
return (
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
<h2 class="text-base sm:text-5xl font-bold">
{count}
</h2>
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
</div>
);
}
export function ActivityCounters(props: ActivityCountersType) {
const { done, learning, streak } = props;
return (
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
<ActivityCounter
text={'Topics Completed'}
count={`${done?.total || 0}`}
/>
<ActivityCounter
text={'Currently Learning'}
count={`${learning?.total || 0}`}
/>
<ActivityCounter
text={'Visit Streak'}
count={`${streak?.count || 0}d`}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'preact/hooks';
import { httpGet } from '../../lib/http';
import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
type ActivityResponse = {
done: {
today: number;
total: number;
};
learning: {
today: number;
total: number;
roadmaps: {
title: string;
id: string;
learning: number;
done: number;
total: number;
skipped: number;
updatedAt: string;
}[];
bestPractices: {
title: string;
id: string;
learning: number;
done: number;
skipped: number;
total: number;
updatedAt: string;
}[];
};
streak: {
count: number;
firstVisitAt: Date | null;
lastVisitAt: Date | null;
};
activity: {
type: 'done' | 'learning' | 'pending' | 'skipped';
createdAt: Date;
metadata: {
resourceId?: string;
resourceType?: 'roadmap' | 'best-practice';
topicId?: string;
topicLabel?: string;
resourceTitle?: string;
};
}[];
};
export function ActivityPage() {
const [activity, setActivity] = useState<ActivityResponse>();
const [isLoading, setIsLoading] = useState(true);
async function loadActivity() {
const { error, response } = await httpGet<ActivityResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-stats`
);
if (!response || error) {
console.error('Error loading activity');
console.error(error);
return;
}
setActivity(response);
}
useEffect(() => {
loadActivity().finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, []);
const learningRoadmaps = activity?.learning.roadmaps || [];
const learningBestPractices = activity?.learning.bestPractices || [];
if (isLoading) {
return null;
}
return (
<>
<ActivityCounters
done={activity?.done || { today: 0, total: 0 }}
learning={activity?.learning || { today: 0, total: 0 }}
streak={activity?.streak || { count: 0 }}
/>
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
{learningRoadmaps.length === 0 &&
learningBestPractices.length === 0 && <EmptyActivity />}
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
<>
<h2 class="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div class="flex flex-col gap-3">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.map((roadmap) => (
<ResourceProgress
doneCount={roadmap.done || 0}
learningCount={roadmap.learning || 0}
totalCount={roadmap.total || 0}
skippedCount={roadmap.skipped || 0}
resourceId={roadmap.id}
resourceType={'roadmap'}
updatedAt={roadmap.updatedAt}
title={roadmap.title}
onCleared={() => {
pageProgressMessage.set('Updating activity');
loadActivity().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
{learningBestPractices
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
const updatedAtB = new Date(b.updatedAt);
return updatedAtB.getTime() - updatedAtA.getTime();
})
.map((bestPractice) => (
<ResourceProgress
doneCount={bestPractice.done || 0}
totalCount={bestPractice.total || 0}
learningCount={bestPractice.learning || 0}
resourceId={bestPractice.id}
skippedCount={bestPractice.skipped || 0}
resourceType={'best-practice'}
title={bestPractice.title}
updatedAt={bestPractice.updatedAt}
onCleared={() => {
pageProgressMessage.set('Updating activity');
loadActivity().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
</div>
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,27 @@
import CheckIcon from '../../icons/roadmap.svg';
export function EmptyActivity() {
return (
<div class="rounded-md">
<div class="flex flex-col items-center p-7 text-center">
<img
alt="no roadmaps"
src={CheckIcon}
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>
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
Progress will appear here as you start tracking your{' '}
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>{' '}
or{' '}
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
Best Practices
</a>{' '}
progress.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
resourceId: string;
title: string;
updatedAt: string;
totalCount: number;
doneCount: number;
learningCount: number;
skippedCount: number;
onCleared: () => void;
};
export function ResourceProgress(props: ResourceProgressType) {
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const {
updatedAt,
resourceType,
resourceId,
title,
totalCount,
learningCount,
doneCount,
skippedCount,
onCleared,
} = props;
async function clearProgress() {
setIsClearing(true);
const { error, response } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
{
resourceId,
resourceType,
}
);
if (error || !response) {
alert('Error clearing progress. Please try again.');
console.error(error);
setIsClearing(false);
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
console.log(`${resourceType}-${resourceId}-progress`);
setIsClearing(false);
setIsConfirming(false);
onCleared();
}
const url =
resourceType === 'roadmap'
? `/${resourceId}`
: `/best-practices/${resourceId}`;
const totalMarked = doneCount + skippedCount;
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
return (
<div>
<a
href={url}
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
>
<span
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
style={{
width: `${progressPercentage}%`,
}}
></span>
<span className="relative flex-1 cursor-pointer truncate">
{title}
</span>
<span className="ml-1 cursor-pointer text-sm text-gray-400">
{getRelativeTimeString(updatedAt)}
</span>
</a>
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
<span className="hidden flex-1 gap-1 sm:flex">
{doneCount > 0 && (
<>
<span>{doneCount} done</span> &bull;
</>
)}
{learningCount > 0 && (
<>
<span>{learningCount} in progress</span> &bull;
</>
)}
{skippedCount > 0 && (
<>
<span>{skippedCount} skipped</span> &bull;
</>
)}
<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>
</>
)}
{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>
)}
</p>
</div>
);
}

View File

@@ -1,41 +1,17 @@
---
---
<script src='./analytics.js'></script>
<script src='./analytics.ts'></script>
<script async src='https://www.googletagmanager.com/gtag/js?id=UA-139582634-1'
></script>
<script is:inline>
// @ts-nocheck
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'UA-139582634-1');
document.addEventListener('click', (e) => {
let trackEl = e.target;
if (!trackEl.getAttribute('ga-category')) {
trackEl = trackEl.closest('[ga-category]');
}
if (!trackEl) {
return;
}
const category = trackEl.getAttribute('ga-category');
const action = trackEl.getAttribute('ga-action');
const label = trackEl.getAttribute('ga-label');
if (!category) {
return;
}
window.fireEvent({
category,
action,
label,
});
});
</script>

View File

@@ -1,35 +1,29 @@
export {};
declare global {
interface Window {
// To selectively enable/disable debug logs
__DEBUG__: boolean;
gtag: any;
fireEvent: (props: GAEventType) => void;
fireEvent: (props: {
action: string;
category: string;
label?: string;
value?: string;
}) => void;
}
}
export type GAEventType = {
action: string;
category: string;
label?: string;
value?: string;
};
/**
* Tracks the event on google analytics
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/events
* @param props Event properties
* @returns void
*/
window.fireEvent = (props: GAEventType) => {
window.fireEvent = (props) => {
const { action, category, label, value } = props;
if (!window.gtag) {
console.warn('Missing GTAG - Analytics disabled');
return;
}
if (window.__DEBUG__) {
if (import.meta.env.DEV) {
console.log('Analytics event fired', props);
}

View File

@@ -35,5 +35,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
const svgAttributes = { ...baseAttributes, ...attributes };
---
<svg {...svgAttributes} set:html={innerHTML}></svg>
<svg {...svgAttributes} set:html={innerHTML}></svg>

View File

@@ -0,0 +1,5 @@
<div class='flex w-full items-center gap-2 py-6 text-sm text-slate-600'>
<div class='h-px w-full bg-slate-200'></div>
OR
<div class='h-px w-full bg-slate-200'></div>
</div>

View File

@@ -0,0 +1,103 @@
import Cookies from 'js-cookie';
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
const EmailLoginForm: FunctionComponent<{}> = () => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleFormSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-login`,
{
email,
password,
}
);
// Log the user in and reload the page
if (response?.token) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
window.location.reload();
return;
}
// @todo use proper types
if ((error as any).type === 'user_not_verified') {
window.location.href = `/verification-pending?email=${encodeURIComponent(
email
)}`;
return;
}
setIsLoading(false);
setError(error?.message || 'Something went wrong. Please try again later.');
};
return (
<form className="w-full" onSubmit={handleFormSubmit}>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
name="email"
type="email"
autoComplete="email"
required
className="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="Email Address"
value={email}
onInput={(e) => setEmail(String((e.target as any).value))}
/>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
name="password"
type="password"
autoComplete="current-password"
required
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="Password"
value={password}
onInput={(e) => setPassword(String((e.target as any).value))}
/>
<p class="mb-3 mt-2 text-sm text-gray-500">
<a
href="/forgot-password"
className="text-blue-800 hover:text-blue-600"
>
Reset your password?
</a>
</p>
{error && (
<p className="mb-2 rounded-md bg-red-100 p-2 text-red-800">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue'}
</button>
</form>
);
};
export default EmailLoginForm;

View File

@@ -0,0 +1,103 @@
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
const EmailSignupForm: FunctionComponent = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-register`,
{
email,
password,
name,
}
);
if (error || response?.status !== 'ok') {
setIsLoading(false);
setError(
error?.message || 'Something went wrong. Please try again later.'
);
return;
}
window.location.href = `/verification-pending?email=${encodeURIComponent(
email
)}`;
};
return (
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
<label htmlFor="name" className="sr-only">
Name
</label>
<input
name="name"
type="text"
autoComplete="name"
min={3}
max={50}
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Full Name"
value={name}
onInput={(e) => setName(String((e.target as any).value))}
/>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Email Address"
value={email}
onInput={(e) => setEmail(String((e.target as any).value))}
/>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
name="password"
type="password"
autoComplete="current-password"
min={6}
max={50}
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Password"
value={password}
onInput={(e) => setPassword(String((e.target as any).value))}
/>
{error && (
<p className="rounded-lg bg-red-100 p-2 text-red-700">{error}.</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}
</button>
</form>
);
};
export default EmailSignupForm;

View File

@@ -0,0 +1,64 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function ForgotPasswordForm() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-forgot-password`,
{
email,
}
);
setIsLoading(false);
if (error) {
setError(error.message);
} else {
setEmail('');
setSuccess('Check your email for a link to reset your password.');
}
};
return (
<form onSubmit={handleSubmit} class="w-full">
<input
type="email"
name="email"
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="Email Address"
value={email}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-sm text-red-700">
{error}
</p>
)}
{success && (
<p className="mt-2 rounded-lg bg-green-100 p-2 text-sm text-green-700">
{success}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue'}
</button>
</form>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'preact/hooks';
import GitHubIcon from '../../icons/github.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
type GitHubButtonProps = {};
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
const GITHUB_LAST_PAGE = 'githubLastPage';
export function GitHubButton(props: GitHubButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : GitHubIcon;
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 !== 'github') {
return;
}
setIsLoading(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
window.location.search
}`
)
.then(({ response, error }) => {
if (!response?.token) {
const errMessage = error?.message || 'Something went wrong.';
setError(errMessage);
setIsLoading(false);
return;
}
let redirectUrl = '/';
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT);
const lastPageBeforeGithub = localStorage.getItem(GITHUB_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 (gitHubRedirectAt && lastPageBeforeGithub) {
const socialRedirectAtTime = parseInt(gitHubRedirectAt, 10);
const now = Date.now();
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeGithub;
}
}
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_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 = async () => {
setIsLoading(true);
const { response, error } = await httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
);
if (error || !response?.loginUrl) {
setError(
error?.message || 'Something went wrong. Please try again later.'
);
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)) {
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
}
window.location.href = response.loginUrl;
};
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="GitHub"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with GitHub
</button>
{error && (
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
)}
</>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import GoogleIcon from '../../icons/google.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
type GoogleButtonProps = {};
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
const GOOGLE_LAST_PAGE = 'googleLastPage';
export function GoogleButton(props: GoogleButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : GoogleIcon;
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 !== 'google') {
return;
}
setIsLoading(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
window.location.search
}`
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
let redirectUrl = '/';
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT);
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_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 (googleRedirectAt && lastPageBeforeGoogle) {
const socialRedirectAtTime = parseInt(googleRedirectAt, 10);
const now = Date.now();
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeGoogle;
}
}
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_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-google-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)) {
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
}
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 Google
</button>
{error && (
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
)}
</>
);
}

View File

@@ -0,0 +1,32 @@
---
import Popup from '../Popup/Popup.astro';
import EmailLoginForm from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
---
<Popup id='login-popup' title='' subtitle=''>
<div class='text-center'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
Login to your account
</h2>
<p class='mt-2 text-sm leading-4 text-slate-600'>
You must be logged in to perform this action.
</p>
</div>
<div class='mt-7 flex flex-col gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
</div>
<Divider />
<EmailLoginForm client:load />
<div class='mt-6 text-center text-sm text-slate-600'>
Don't have an account?{' '}
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
</div>
</Popup>

View File

@@ -0,0 +1,100 @@
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export default function ResetPasswordForm() {
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (!code) {
window.location.href = '/login';
} else {
setCode(code);
}
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
if (password !== passwordConfirm) {
setIsLoading(false);
setError('Passwords do not match.');
return;
}
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-reset-forgotten-password`,
{
newPassword: password,
confirmPassword: passwordConfirm,
code,
}
);
if (error?.message) {
setIsLoading(false);
setError(error.message);
return;
}
if (!response?.token) {
setIsLoading(false);
setError('Something went wrong. Please try again later.');
return;
}
const token = response.token;
Cookies.set(TOKEN_COOKIE_NAME, token, {
path: '/',
expires: 30,
});
window.location.href = '/';
};
return (
<form className="mx-auto w-full" onSubmit={handleSubmit}>
<input
type="password"
className="mb-2 mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="New Password"
value={password}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
<input
type="password"
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="Confirm New Password"
value={passwordConfirm}
onInput={(e) =>
setPasswordConfirm((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Reset Password'}
</button>
</form>
);
}

View File

@@ -0,0 +1,82 @@
import SpinnerIcon from '../../icons/spinner.svg';
import ErrorIcon from '../../icons/error.svg';
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
export function TriggerVerifyAccount() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const triggerVerify = (code: string) => {
setIsLoading(true);
httpPost<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
{
code,
}
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong. Please try again.');
setIsLoading(false);
return;
}
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
});
window.location.href = '/';
})
.catch((err) => {
setIsLoading(false);
setError('Something went wrong. Please try again.');
});
};
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code')!;
if (!code) {
setIsLoading(false);
setError('Something went wrong. Please try again later.');
return;
}
triggerVerify(code);
}, []);
return (
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
<div className="mx-auto max-w-md text-center">
{isLoading && (
<img
alt={'Please wait.'}
src={SpinnerIcon}
class={'mx-auto h-16 w-16 animate-spin'}
/>
)}
{error && (
<img
alt={'Please wait.'}
src={ErrorIcon}
className={'mx-auto h-16 w-16'}
/>
)}
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
Verifying your account
</h2>
<div className="text-sm sm:text-base">
{isLoading && <p>Please wait while we verify your account..</p>}
{error && <p class="text-red-700">{error}</p>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import VerifyLetterIcon from '../../icons/verify-letter.svg';
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function VerificationEmailMessage() {
const [email, setEmail] = useState('..');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isEmailResent, setIsEmailResent] = useState(false);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setEmail(urlParams.get('email')!);
}, []);
const resendVerificationEmail = () => {
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-send-verification-email`, {
email,
})
.then(({ response, error }) => {
if (error) {
setIsEmailResent(false);
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
setIsEmailResent(true);
})
.catch(() => {
setIsEmailResent(false);
setIsLoading(false);
setError('Something went wrong. Please try again later.');
});
};
return (
<div className="mx-auto max-w-md text-center">
<img
alt="Verify Email"
src={VerifyLetterIcon}
class="mx-auto mb-4 h-20 w-40 sm:h-40"
/>
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
Verify your email address
</h2>
<div class="text-sm sm:text-base">
<p>
We have sent you an email at{' '}
<span className="font-bold">{email}</span>. Please click the link to
verify your account. This link will expire shortly, so please verify
soon!
</p>
<hr class="my-4" />
{!isEmailResent && (
<>
{isLoading && <p className="text-gray-400">Sending the email ..</p>}
{!isLoading && !error && (
<p>
Please make sure to check your spam folder. If you still don't
have the email click to{' '}
<button
disabled={!email}
className="inline text-blue-700"
onClick={resendVerificationEmail}
>
resend verification email.
</button>
</p>
)}
{error && <p class="text-red-700">{error}</p>}
</>
)}
{isEmailResent && (
<p class="text-green-700">Verification email has been sent!</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
---
---
<script src='./authenticator.ts'></script>

View File

@@ -0,0 +1,80 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
function easeInElement(el: Element) {
el.classList.add('opacity-0', 'transition-opacity', 'duration-300');
el.classList.remove('hidden');
setTimeout(() => {
el.classList.remove('opacity-0');
});
}
function showHideAuthElements(hideOrShow: 'hide' | 'show' = 'hide') {
document.querySelectorAll('[data-auth-required]').forEach((el) => {
if (hideOrShow === 'hide') {
el.classList.add('hidden');
} else {
easeInElement(el);
}
});
}
function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
document.querySelectorAll('[data-guest-required]').forEach((el) => {
if (hideOrShow === 'hide') {
el.classList.add('hidden');
} else {
easeInElement(el);
}
});
}
// Prepares the UI for the user who is logged in
function handleGuest() {
const authenticatedRoutes = [
'/account/update-profile',
'/account/update-password',
'/account',
];
showHideAuthElements('hide');
showHideGuestElements('show');
// If the user is on an authenticated route, redirect them to the home page
if (authenticatedRoutes.includes(window.location.pathname)) {
window.location.href = '/';
}
}
// Prepares the UI for the user who is logged out
function handleAuthenticated() {
const guestRoutes = [
'/login',
'/signup',
'/verify-account',
'/verification-pending',
'/reset-password',
'/forgot-password',
];
showHideGuestElements('hide');
showHideAuthElements('show');
// If the user is on a guest route, redirect them to the home page
if (guestRoutes.includes(window.location.pathname)) {
window.location.href = '/';
}
}
export function handleAuthRequired() {
const token = Cookies.get(TOKEN_COOKIE_NAME);
if (token) {
handleAuthenticated();
} else {
handleGuest();
}
}
window.setTimeout(() => {
handleAuthRequired();
}, 0);

View File

@@ -1,8 +1,7 @@
---
import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import BestPracticeHint from './BestPracticeHint.astro';
import DownloadPopup from './DownloadPopup.astro';
import Icon from './Icon.astro';
import SubscribePopup from './SubscribePopup.astro';
export interface Props {
title: string;
@@ -15,23 +14,22 @@ const { title, description, bestPracticeId, isUpcoming = false } = Astro.props;
const isBestPracticeReady = !isUpcoming;
---
<DownloadPopup />
<SubscribePopup />
<LoginPopup />
<div class='border-b'>
<div class='py-5 sm:py-12 container relative'>
<div class='mt-0 mb-3 sm:mb-6'>
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
<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}
</h1>
<p class='text-gray-500 text-sm 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'>
<a
href='/best-practices'
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
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>
@@ -40,26 +38,37 @@ const isBestPracticeReady = !isUpcoming;
{
isBestPracticeReady && (
<button
data-popup='download-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
aria-label='Download Best Practice'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Download Best Practice Popup'
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'
>
<Icon icon='download' />
<span class='hidden sm:inline ml-2'>Download</span>
<span class='ml-2 hidden sm:inline'>Download</span>
</button>
)
}
{
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'
target="_blank"
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</a>
)
}
<button
data-popup='subscribe-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
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'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Subscribe Best Practice Popup'
>
<Icon icon='email' />
<span class='ml-2'>Subscribe</span>
@@ -71,7 +80,7 @@ const isBestPracticeReady = !isUpcoming;
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
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' />

View File

@@ -10,7 +10,7 @@ const { breadcrumbs, roadmapId } = Astro.props;
---
<div class='py-7 pb-6'>
<!-- Desktop breadcrums -->
<!-- Desktop breadcrumbs -->
<p class='text-gray-500 container hidden sm:block'>
{
breadcrumbs.map((breadcrumb, counter) => {

View File

@@ -1,5 +0,0 @@
---
---
<div class='recaptcha-field mb-2'></div>
<input type='hidden' name='g-recaptcha-response' class='recaptcha-response' />

View File

@@ -1,36 +0,0 @@
---
---
<script src='./captcha.js'></script>
<script is:inline>
window.onCaptchaLoad = function () {
if (!window.grecaptcha) {
console.warn('window.grecaptcha is not defined');
return;
}
const recaptchaFields = document.querySelectorAll('.recaptcha-field');
// render recaptcha on fields
recaptchaFields.forEach((field) => {
// If captcha already rendered for this field
if (field.hasAttribute('data-recaptcha-id')) {
return;
}
const renderedId = window.grecaptcha.render(field, {
sitekey: '6Ldn2YsjAAAAABlUxNxukAuDAUIuZIhO0hRVxzJW',
});
field.setAttribute('data-recaptcha-id', renderedId);
});
};
</script>
<script
src='https://www.google.com/recaptcha/api.js?onload=onCaptchaLoad&render=explicit'
async
defer
></script>

View File

@@ -1,49 +0,0 @@
class Captcha {
constructor() {
this.onDOMLoaded = this.onDOMLoaded.bind(this);
this.bindValidation = this.bindValidation.bind(this);
this.validateCaptchaBeforeSubmit =
this.validateCaptchaBeforeSubmit.bind(this);
}
validateCaptchaBeforeSubmit(e) {
const target = e.target;
const captchaField = target.querySelector('.recaptcha-field');
if (captchaField) {
const captchaId = captchaField.dataset.recaptchaId;
const captchaResponse = window.grecaptcha.getResponse(captchaId);
// If valid captcha is not present, prevent form submission
if (!captchaResponse) {
e.preventDefault();
alert('Please verify that you are human first');
return false;
}
target.querySelector('.recaptcha-response').value = captchaResponse;
}
target.closest('.popup').classList.add('hidden');
return true;
}
bindValidation() {
const forms = document.querySelectorAll('[captcha-form]');
forms.forEach((form) => {
form.addEventListener('submit', this.validateCaptchaBeforeSubmit);
});
}
onDOMLoaded() {
this.bindValidation();
}
init() {
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
}
}
const captcha = new Captcha();
captcha.init();

View File

@@ -0,0 +1,204 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import BestPracticesIcon from '../../icons/best-practices.svg';
import GuideIcon from '../../icons/guide.svg';
import HomeIcon from '../../icons/home.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
import UserIcon from '../../icons/user.svg';
import VideoIcon from '../../icons/video.svg';
import { httpGet } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
type PageType = {
url: string;
title: string;
group: string;
icon?: string;
isProtected?: boolean;
};
const defaultPages: PageType[] = [
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{
url: '/account',
title: 'Account',
group: 'Pages',
icon: UserIcon,
isProtected: true,
},
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
{
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 },
];
function shouldShowPage(page: PageType) {
const isUser = isLoggedIn();
return !page.isProtected || isUser;
}
export function CommandMenu() {
const inputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLInputElement>(null);
const [isActive, setIsActive] = useState(false);
const [allPages, setAllPages] = useState<PageType[]>([]);
const [searchResults, setSearchResults] = useState<PageType[]>(defaultPages);
const [searchedText, setSearchedText] = useState('');
const [activeCounter, setActiveCounter] = useState(0);
useKeydown('mod_k', () => {
setIsActive(true);
});
useOutsideClick(modalRef, () => {
setSearchedText('');
setIsActive(false);
});
useEffect(() => {
function handleToggleTopic(e: any) {
setIsActive(true);
}
getAllPages();
window.addEventListener(`command.k`, handleToggleTopic);
return () => {
window.removeEventListener(`command.k`, handleToggleTopic);
};
}, []);
useEffect(() => {
if (!isActive || !inputRef.current) {
return;
}
inputRef.current.focus();
}, [isActive]);
async function getAllPages() {
if (allPages.length > 0) {
return allPages;
}
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (!response) {
return defaultPages.filter(shouldShowPage);
}
setAllPages([...defaultPages, ...response].filter(shouldShowPage));
return response;
}
useEffect(() => {
if (!searchedText) {
setSearchResults(defaultPages.filter(shouldShowPage));
return;
}
const normalizedSearchText = searchedText.trim().toLowerCase();
getAllPages().then((unfilteredPages = defaultPages) => {
const filteredPages = unfilteredPages
.filter((currPage: PageType) => {
return (
currPage.title.toLowerCase().indexOf(normalizedSearchText) !== -1
);
})
.slice(0, 10);
setActiveCounter(0);
setSearchResults(filteredPages);
});
}, [searchedText]);
if (!isActive) {
return null;
}
return (
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
<input
ref={inputRef}
autofocus={true}
type="text"
value={searchedText}
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
placeholder="Search roadmaps, guides or pages .."
autocomplete="off"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
setSearchedText(value);
}}
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') {
e.preventDefault();
} else if (e.key === 'Escape') {
setSearchedText('');
setIsActive(false);
} else if (e.key === 'Enter') {
const activePage = searchResults[activeCounter];
if (activePage) {
window.location.href = activePage.url;
}
}
}}
/>
<div class="px-2 py-2">
<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((page, counter) => {
const prevPage = searchResults[counter - 1];
const groupChanged = prevPage && prevPage.group !== page.group;
return (
<>
{groupChanged && (
<div class="border-b border-gray-100"></div>
)}
<a
class={`flex w-full items-center rounded p-2 text-sm ${
counter === activeCounter ? 'bg-gray-100' : ''
}`}
onMouseOver={() => setActiveCounter(counter)}
href={page.url}
>
{!page.icon && (
<span class="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && (
<img src={page.icon} class="mr-2 h-4 w-4" />
)}
{page.title}
</a>
</>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,50 +0,0 @@
---
import Popup from './Popup/Popup.astro';
import CaptchaFields from './Captcha/CaptchaFields.astro';
---
<Popup id='download-popup' title='Download' subtitle='Enter your email below to receive the download link.'>
<form
action='https://news.roadmap.sh/subscribe'
method='POST'
accept-charset='utf-8'
target='_blank'
captcha-form
>
<input type='hidden' name='gdpr' value='true' />
<input
type='email'
name='email'
id='email'
required
autofocus
class='w-full rounded-md border text-md py-2.5 px-3 mb-2'
placeholder='Enter your Email'
/>
<CaptchaFields />
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
<input type='hidden' name='subform' value='yes' />
<button
type='submit'
name='submit'
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2'
submit-download-form
>
Send Link
</button>
</form>
</Popup>
<script>
document.querySelector('[submit-download-form]')?.addEventListener('click', () => {
window.fireEvent({
category: 'Subscription',
action: 'Submitted Popup Form',
label: 'Download Roadmap Popup',
});
});
</script>

View File

@@ -1,3 +1,3 @@
<div class='text-sm sm:text-base leading-relaxed text-left p-2 sm:p-4 text-md text-gray-800 border-t border-t-gray-300 bg-gray-100 rounded-bl-md rounded-br-md [&>p:not(:last-child)]:mb-3 [&>p>a]:underline [&>p>a]:text-blue-500'>
<div class='text-sm sm:text-base leading-relaxed text-left p-2 sm:p-4 text-md text-gray-800 border-t border-t-gray-300 bg-gray-100 rounded-bl-md rounded-br-md [&>p:not(:last-child)]:mb-3 [&>p>a]:underline [&>p>a]:text-blue-700'>
<slot />
</div>

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export interface Props {
question: string;

View File

@@ -9,13 +9,15 @@ export interface Props {
const { featuredItems, heading } = Astro.props;
---
<div class='py-4 sm:py-14 border-b border-b-[#1e293c] relative'>
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
<div class='container'>
<h2 class='hidden sm:flex absolute rounded-lg -top-[17px] left-1/2 -translate-x-1/2 bg-slate-900 py-1 px-3 border border-[#1e293c] text-md text-slate-400 font-regular'>
<h2
class='text-md font-regular absolute flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 -top-[17px] sm:left-1/2 sm:-translate-x-1/2'
>
{heading}
</h2>
<ul class='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2'>
<ul class='grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3'>
{
featuredItems.map((featuredItem) => (
<li>

View File

@@ -1,58 +1,66 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
---
<div class='py-6 sm:py-16 pb-10 bg-slate-900 text-white'>
<div class='bg-slate-900 py-6 pb-10 text-white sm:py-16'>
<div class='container'>
<p class='text-gray-400 font-medium flex flex-col sm:flex-row gap-0 sm:gap-4 mb-8 sm:mb-16 justify-center'>
<p
class='mb-8 flex flex-col justify-center gap-0 font-medium text-gray-400 sm:mb-16 sm:flex-row sm:gap-4'
>
<a
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
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='/roadmaps'>Roadmaps</a
>
<a
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
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='/best-practices'>Best Practices</a
>
<a
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='/guides'>Guides</a
>
<a
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
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='/videos'>Videos</a
>
<a
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
href='/about'>About</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
>
<a
class='transition-colors px-2 py-1.5 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'>YouTube</a
>
</p>
<div class='flex flex-col sm:flex-row justify-between gap-12'>
<div class='flex flex-col justify-between gap-12 sm:flex-row'>
<div class='max-w-[365px]'>
<p class='flex items-center text-md'>
<p class='text-md flex items-center'>
<a
class='font-medium text-lg inline-flex items-center text-white transition-colors hover:text-gray-400'
class='inline-flex items-center text-lg font-medium text-white transition-colors hover:text-gray-400'
href='/'
>
<Icon icon='logo' />
<span class='ml-2'>roadmap.sh</span>
</a>
<span class='text-gray-400 mx-2'>by</span>
<span class='mx-2 text-gray-400'>by</span>
<a
class='bg-blue-600 text-sm py-1 px-1.5 font-regular hover:bg-blue-700 rounded-md'
href='https://twitter.com/intent/user?screen_name=kamranahmedse'
class='font-regular rounded-md bg-blue-600 px-1.5 py-1 text-sm hover:bg-blue-700'
href='https://twitter.com/intent/user?screen_name=kamrify'
target='_blank'
>
<span class='hidden sm:inline'>@kamranahmedse</span>
<span class='hidden sm:inline'>@kamrify</span>
<span class='inline sm:hidden'>Kamran Ahmed</span>
</a>
</p>
<p class='text-slate-300/60 my-4'>
Community created roadmaps, articles, resources and journeys to help you choose your path and grow in your
career.
<p class='my-4 text-slate-300/60'>
Community created roadmaps, articles, resources and journeys to help
you choose your path and grow in your career.
</p>
<div class='text-gray-400 text-sm'>
<div class='text-sm text-gray-400'>
<p>
&copy; roadmap.sh
<span class='mx-1.5'>&middot;</span>
@@ -65,46 +73,37 @@ import Icon from './Icon.astro';
</div>
</div>
<div class='text-left sm:text-right max-w-[365px]'>
<div class='max-w-[365px] text-left sm:text-right'>
<a href='https://thenewstack.io' target='_blank'>
<img
src='/images/tns-sm.png'
alt='ThewNewStack'
class='my-1.5 mr-auto sm:mr-0 sm:ml-auto'
class='my-1.5 mr-auto sm:ml-auto sm:mr-0'
width='200'
height='24.8'
/>
</a>
<p class='text-slate-300/60 my-4'>
The leading DevOps resource for Kubernetes, cloud-native computing, and the latest in at-scale development,
deployment, and management.
<p class='my-4 text-slate-300/60'>
The leading DevOps resource for Kubernetes, cloud-native computing,
and the latest in at-scale development, deployment, and management.
</p>
<div class='text-gray-400 text-sm'>
<div class='text-sm text-gray-400'>
<p>
<a
href='https://thenewstack.io/category/devops?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Footer'
target='_blank'
ga-category='PartnerClick'
ga-action='TNS Referral'
ga-label='TNS Referral - Footer'
class='text-gray-400 hover:text-white'>DevOps</a
>
<span class='mx-1.5'>&middot;</span>
<a
href='https://thenewstack.io/category/kubernetes?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Footer'
target='_blank'
ga-category='PartnerClick'
ga-action='TNS Referral'
ga-label='TNS Referral - Footer'
class='text-gray-400 hover:text-white'>Kubernetes</a
>
<span class='mx-1.5'>&middot;</span>
<a
href='https://thenewstack.io/category/cloud-native?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Footer'
target='_blank'
ga-category='PartnerClick'
ga-action='TNS Referral'
ga-label='TNS Referral - Footer'
class='text-gray-400 hover:text-white'>Cloud-Native</a
>
</p>

View File

@@ -5,26 +5,26 @@ import './FrameRenderer.css';
export interface Props {
resourceType: 'roadmap' | 'best-practice';
resourceId: string;
jsonUrl: string;
dimensions?: {
width: number;
height: number;
};
}
const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
const { resourceId, resourceType, dimensions = null } = Astro.props;
---
<div
id='resource-svg-wrap'
style={dimensions ? `--aspect-ratio:${dimensions.width}/${dimensions.height}` : null}
style={dimensions
? `--aspect-ratio:${dimensions.width}/${dimensions.height}`
: null}
data-resource-type={resourceType}
data-resource-id={resourceId}
data-json-url={jsonUrl}
>
<div id='resource-loader'>
<Loader />
</div>
</div>
<script src='./renderer.js'></script>
<script src='./renderer.ts'></script>

View File

@@ -49,10 +49,27 @@ svg .done rect {
fill: #cbcbcb !important;
}
svg .done text {
svg .done text, svg .skipped text {
text-decoration: line-through;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .skipped rect {
fill: #496b69!important;
}
svg .learning rect[fill='rgb(51,51,51)'] + text,
svg .done rect[fill='rgb(51,51,51)'] + text {
fill: black !important;
}
svg .learning text {
text-decoration: underline;
}
svg .clickable-group.done[data-group-id^='check:'] rect {
fill: gray !important;
stroke: gray;

View File

@@ -1,6 +1,20 @@
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { httpPost } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
import {
renderResourceProgress,
ResourceType,
} from '../../lib/resource-progress';
export class Renderer {
resourceId: string;
resourceType: string;
jsonUrl: string;
loaderHTML: string | null;
containerId: string;
loaderId: string;
constructor() {
this.resourceId = '';
this.resourceType = '';
@@ -32,12 +46,11 @@ export class Renderer {
}
// Clone it so we can use it later
this.loaderHTML = this.loaderEl.innerHTML;
this.loaderHTML = this.loaderEl!.innerHTML;
const dataset = this.containerEl.dataset;
this.resourceType = dataset.resourceType;
this.resourceId = dataset.resourceId;
this.jsonUrl = dataset.jsonUrl;
this.resourceType = dataset.resourceType!;
this.resourceId = dataset.resourceId!;
return true;
}
@@ -46,13 +59,17 @@ export class Renderer {
* @param { string } jsonUrl
* @returns {Promise<SVGElement>}
*/
jsonToSvg(jsonUrl) {
jsonToSvg(jsonUrl: string) {
if (!jsonUrl) {
console.error('jsonUrl not defined in frontmatter');
return null;
}
this.containerEl.innerHTML = this.loaderHTML;
if (!this.containerEl) {
return null;
}
this.containerEl.innerHTML = this.loaderHTML!;
return fetch(jsonUrl)
.then((res) => {
@@ -64,9 +81,19 @@ export class Renderer {
});
})
.then((svg) => {
this.containerEl.replaceChildren(svg);
this.containerEl?.replaceChildren(svg);
})
.then(() => {
return renderResourceProgress(
this.resourceType as ResourceType,
this.resourceId
);
})
.catch((error) => {
if (!this.containerEl) {
return;
}
const message = `
<strong>There was an error.</strong><br>
@@ -74,11 +101,23 @@ export class Renderer {
${error.message} <br /> ${error.stack}
`;
this.containerEl.innerHTML = `<div class="error py-5 text-center text-red-600 mx-auto">${message}</div>`;
});
}
trackVisit() {
if (!isLoggedIn()) {
return;
}
window.setTimeout(() => {
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
resourceId: this.resourceId,
resourceType: this.resourceType,
}).then(() => null);
}, 0);
}
onDOMLoaded() {
if (!this.prepareConfig()) {
return;
@@ -87,45 +126,43 @@ export class Renderer {
const urlParams = new URLSearchParams(window.location.search);
const roadmapType = urlParams.get('r');
this.trackVisit();
if (roadmapType) {
this.switchRoadmap(`/jsons/roadmaps/${roadmapType}.json`);
this.switchRoadmap(`/${roadmapType}.json`);
} else {
this.jsonToSvg(this.jsonUrl);
this.jsonToSvg(
this.resourceType === 'roadmap'
? `/${this.resourceId}.json`
: `/best-practices/${this.resourceId}.json`
);
}
}
switchRoadmap(newJsonUrl) {
const newJsonFileSlug = newJsonUrl.split('/').pop().replace('.json', '');
switchRoadmap(newJsonUrl: string) {
this.containerEl?.setAttribute('style', '');
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
// Update the URL and attach the new roadmap type
if (window?.history?.pushState) {
const url = new URL(window.location);
const url = new URL(window.location.href);
const type = this.resourceType[0]; // r for roadmap, b for best-practices
url.searchParams.delete(type);
url.searchParams.set(type, newJsonFileSlug);
if (newJsonFileSlug !== this.resourceId) {
url.searchParams.set(type, newJsonFileSlug!);
}
window.history.pushState(null, '', url.toString());
}
const pageType = this.resourceType.replace(/\b\w/g, (l) => l.toUpperCase());
window.fireEvent({
// RoadmapClick, BestPracticesClick, etc
category: `${pageType.replace('-', '')}Click`,
// roadmap/frontend/switch-version
action: `${this.resourceId}/switch-version`,
// roadmap/frontend/switch-version
label: `${newJsonFileSlug}`,
});
this.jsonToSvg(newJsonUrl).then(() => {
this.containerEl.setAttribute('style', '');
});
this.jsonToSvg(newJsonUrl)?.then(() => {});
}
handleSvgClick(e) {
const targetGroup = e.target.closest('g') || {};
handleSvgClick(e: any) {
const targetGroup = e.target?.closest('g') || {};
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
@@ -134,7 +171,17 @@ export class Renderer {
e.stopImmediatePropagation();
if (/^ext_link/.test(groupId)) {
window.open(`https://${groupId.replace('ext_link:', '')}`);
const externalLink = groupId.replace('ext_link:', '');
if (!externalLink.startsWith('roadmap.sh')) {
window.fireEvent({
category: 'RoadmapExternalLink',
action: `${this.resourceType} / ${this.resourceId}`,
label: externalLink,
});
}
window.open(`https://${externalLink}`);
return;
}
@@ -167,6 +214,7 @@ export class Renderer {
detail: {
topicId: normalizedGroupId,
resourceId: this.resourceId,
resourceType: this.resourceType,
},
})
);
@@ -175,6 +223,7 @@ export class Renderer {
init() {
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
window.addEventListener('click', this.handleSvgClick);
// window.addEventListener('contextmenu', this.handleSvgClick);
}
}

View File

@@ -32,7 +32,7 @@ const { author } = frontmatter;
<span class='mx-1.5'>&middot;</span>
<a
class='text-blue-400 hover:text-blue-500 hover:underline'
href={`https://github.com/kamranahmedse/roadmap.sh/tree/master/src/data/guides/${guide.id}.md`}
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/guides/${guide.id}.md`}
target='_blank'>Improve this Guide</a
>
</p>

View File

@@ -1,5 +1,5 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
---
<div class='flex justify-center w-full'>

View File

@@ -1,5 +1,5 @@
<div
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-4 prose-h2:mb-2 prose-h3:mt-2 prose-img:mt-1'
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-3 prose-h3:mt-2 prose-img:mt-1'
>
<slot />
</div>

View File

@@ -1,79 +0,0 @@
---
import Icon from './Icon.astro';
---
<div class='bg-slate-900 text-white py-5 sm:py-8'>
<nav class='container flex items-center justify-between'>
<a class='font-medium text-lg flex items-center text-white' href='/'>
<Icon icon='logo' />
<span class='ml-3'>roadmap.sh</span>
</a>
<!-- Desktop navigation items -->
<ul class='hidden sm:flex space-x-5'>
<li>
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-gray-400 hover:text-white'>Best Practices</a>
</li>
<li>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
</li>
<li>
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
</li>
<li>
<a
class='py-2 px-4 text-sm font-regular rounded-full bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white'
href='/signup'
>
Subscribe
</a>
</li>
</ul>
<!-- Mobile Navigation Button -->
<button class='text-gray-400 hover:text-gray-50 block sm:hidden cursor-pointer' aria-label='Menu' show-mobile-nav>
<Icon icon='hamburger' />
</button>
<!-- Mobile Navigation Items -->
<div class='fixed top-0 bottom-0 left-0 right-0 z-40 bg-slate-900 items-center flex hidden' mobile-nav>
<button
close-mobile-nav
class='text-gray-400 hover:text-gray-50 block cursor-pointer absolute top-6 right-6'
aria-label='Close Menu'
>
<Icon icon='close' />
</button>
<ul class='flex flex-col gap-2 md:gap-3 items-center w-full'>
<li>
<a href='/roadmaps' class='text-xl md:text-lg hover:text-blue-300'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-xl md:text-lg hover:text-blue-300'>Best Practices</a>
</li>
<li>
<a href='/guides' class='text-xl md:text-lg hover:text-blue-300'>Guides</a>
</li>
<li>
<a href='/videos' class='text-xl md:text-lg hover:text-blue-300'>Videos</a>
</li>
<li>
<a href='/signup' class='text-xl md:text-lg text-red-300 hover:text-red-400'>Subscribe</a>
</li>
</ul>
</div>
</nav>
</div>
<script>
document.querySelector('[show-mobile-nav]')?.addEventListener('click', () => {
document.querySelector('[mobile-nav]')?.classList.remove('hidden');
});
document.querySelector('[close-mobile-nav]')?.addEventListener('click', () => {
document.querySelector('[mobile-nav]')?.classList.add('hidden');
});
</script>

View File

@@ -0,0 +1,44 @@
---
import Icon from '../AstroIcon.astro';
---
<div class='relative hidden' data-auth-required>
<button
class='flex h-8 w-28 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600'
type='button'
data-account-button
>
<span class='inline-flex items-center gap-1.5'>
Account
<Icon
icon='chevron-down'
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
/>
</span>
</button>
<div
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
data-account-dropdown
>
<ul>
<li class='px-1'>
<a
href='/account'
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
>
Profile
</a>
</li>
<li class='px-1'>
<button
class='block w-full rounded px-4 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700'
type='button'
data-logout-button
>
Logout
</button>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,138 @@
---
import Icon from '../AstroIcon.astro';
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">
<Icon icon='logo' />
</a>
<!-- Desktop navigation items -->
<ul class='hidden space-x-5 sm:flex sm:items-center'>
<li>
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-gray-400 hover:text-white'
>Best Practices</a
>
</li>
<li class='hidden lg:inline'>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
</li>
<li class='hidden lg:inline'>
<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>
</li>
</ul>
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
<li data-guest-required class='hidden'>
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
</li>
<li>
<AccountDropdown />
<a
data-guest-required
class='flex hidden h-8 w-28 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-blue-700 px-4 py-2 text-sm font-medium text-white hover:from-blue-500 hover:to-blue-600'
href='/signup'
>
<span>Sign Up</span>
</a>
</li>
</ul>
<!-- Mobile Navigation Button -->
<button
class='block cursor-pointer text-gray-400 hover:text-gray-50 sm:hidden'
aria-label='Menu'
data-show-mobile-nav
>
<Icon icon='hamburger' />
</button>
<!-- Mobile Navigation Items -->
<div
class='fixed bottom-0 left-0 right-0 top-0 z-40 flex hidden items-center bg-slate-900'
data-mobile-nav
>
<button
data-close-mobile-nav
class='absolute right-6 top-6 block cursor-pointer text-gray-400 hover:text-gray-50'
aria-label='Close Menu'
>
<Icon icon='close' />
</button>
<ul class='flex w-full flex-col items-center gap-2 md:gap-3'>
<li>
<a href='/roadmaps' class='text-xl hover:text-blue-300 md:text-lg'>
Roadmaps
</a>
</li>
<li>
<a
href='/best-practices'
class='text-xl hover:text-blue-300 md:text-lg'
>
Best Practices
</a>
</li>
<li>
<a href='/guides' class='text-xl hover:text-blue-300 md:text-lg'>
Guides
</a>
</li>
<li>
<a href='/videos' class='text-xl hover:text-blue-300 md:text-lg'>
Videos
</a>
</li>
<!-- Links for logged in users -->
<li data-auth-required class='hidden'>
<a
href='/account'
class='text-xl hover:text-blue-300 md:text-lg'
>
Account
</a>
</li>
<li data-auth-required class='hidden'>
<button
data-logout-button
class='text-xl text-red-300 hover:text-red-400 md:text-lg'
>
Logout
</button>
</li>
<li>
<a
data-guest-required
href='/login'
class='hidden text-xl text-white md:text-lg'
>
Login
</a>
</li>
<li>
<a
data-guest-required
href='/signup'
class='hidden text-xl text-green-300 hover:text-green-400 md:text-lg'
>
Sign Up
</a>
</li>
</ul>
</div>
</nav>
</div>
<script src='./navigation.ts'></script>

View File

@@ -0,0 +1,44 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from "../../lib/jwt";
export function logout() {
Cookies.remove(TOKEN_COOKIE_NAME);
// Reloading will automatically redirect the user if required
window.location.reload();
}
function bindEvents() {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const dataset = {
...target.dataset,
...target.closest('button')?.dataset,
};
// If the user clicks on the logout button, remove the token cookie
if (dataset.logoutButton !== undefined) {
logout();
} else if (dataset.showMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
} else if (dataset.closeMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
}
});
document
.querySelector('[data-account-button]')
?.addEventListener('click', (e) => {
e.stopPropagation();
document
.querySelector('[data-account-dropdown]')
?.classList.toggle('hidden');
});
document
.querySelector('[data-command-menu]')
?.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('command.k'));
});
}
bindEvents();

View File

@@ -1,6 +1,6 @@
---
import { getFormattedStars } from '../lib/github';
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
---
@@ -30,12 +30,12 @@ const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
</a>
<a
href='https://discord.gg/cJpEt5Qbwa'
href="https://discord.gg/cJpEt5Qbwa"
target='_blank'
class='relative pointer inline-flex items-center border border-black py-1.5 px-3 rounded-lg text-sm hover:text-white hover:bg-black bg-white group'
>
<Icon icon='discord' class='h-[14px] mr-2 -ml-1 fill-current' />
Join on Discord <span class="rounded-sm ml-0.5 px-1.5 py-0.5 text-xs uppercase">/ New</span>
Join on Discord
</a>
</div>
</div>

View File

@@ -0,0 +1,46 @@
import { useStore } from '@nanostores/preact';
import SpinnerIcon from '../icons/spinner.svg';
import { pageProgressMessage } from '../stores/page';
import { useEffect, useState } from 'preact/hooks';
export interface Props {
initialMessage: string;
}
export function PageProgress(props: Props) {
const { initialMessage } = props;
const [message, setMessage] = useState(initialMessage);
const $pageProgressMessage = useStore(pageProgressMessage);
useEffect(() => {
if ($pageProgressMessage === undefined) {
return;
}
setMessage($pageProgressMessage);
}, [$pageProgressMessage]);
if (!message) {
return null;
}
return (
<div>
{/* Tailwind based spinner for full page */}
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
<div class="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
<img
src={SpinnerIcon}
alt="Loading"
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
/>
<h1 className="ml-2">
{message}
<span className="animate-pulse">...</span>
</h1>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import CloseIcon from '../icons/close.svg';
import { httpGet } from '../lib/http';
import { sponsorHidden } from '../stores/page';
export type PageSponsorType = {
company: string;
description: string;
gaLabel: string;
imageUrl: string;
pageUrl: string;
title: string;
url: string;
};
type V1GetSponsorResponse = {
href?: string;
sponsor?: PageSponsorType;
};
type PageSponsorProps = {
gaPageIdentifier?: string;
};
export function PageSponsor(props: PageSponsorProps) {
const { gaPageIdentifier } = props;
const $isSponsorHidden = useStore(sponsorHidden);
const [sponsor, setSponsor] = useState<PageSponsorType>();
const loadSponsor = async () => {
const { response, error } = await httpGet<V1GetSponsorResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
{
href: window.location.pathname,
}
);
if (error) {
console.error(error);
return;
}
if (!response?.sponsor) {
return;
}
setSponsor(response.sponsor);
window.fireEvent({
category: 'SponsorImpression',
action: `${response.sponsor?.company} Impression`,
label:
response.sponsor.gaLabel ||
`${gaPageIdentifier} / ${response.sponsor?.company} Link`,
});
};
useEffect(() => {
window.setTimeout(loadSponsor);
}, []);
if ($isSponsorHidden || !sponsor) {
return null;
}
const { url, title, imageUrl, description, company, gaLabel, pageUrl } =
sponsor;
return (
<a
href={url}
target="_blank"
rel="noopener sponsored nofollow"
class="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
onClick={() => {
window.fireEvent({
category: 'SponsorClick',
action: `${company} Redirect`,
label: gaLabel || `${gaPageIdentifier} / ${company} Link`,
});
}}
>
<span
class="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
aria-label="Close"
onClick={(e) => {
e.preventDefault();
e.stopImmediatePropagation();
sponsorHidden.set(true);
}}
>
<img alt="Close" class="h-4 w-4" src={CloseIcon} />
</span>
<img
src={imageUrl}
class="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
alt="Sponsor Banner"
/>
<span class="flex flex-1 flex-col justify-between text-sm">
<span class="p-[10px]">
<span class="mb-0.5 block font-semibold">{title}</span>
<span class="block text-gray-500">{description}</span>
</span>
<span class="sponsor-footer">Partner Content</span>
</span>
</a>
);
}

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export interface Props {
id: string;

View File

@@ -1,9 +1,8 @@
---
import DownloadPopup from './DownloadPopup.astro';
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import RoadmapHint from './RoadmapHint.astro';
import RoadmapNote from './RoadmapNote.astro';
import SubscribePopup from './SubscribePopup.astro';
import TopicSearch from './TopicSearch/TopicSearch.astro';
import YouTubeAlert from './YouTubeAlert.astro';
@@ -18,23 +17,31 @@ export interface Props {
hasTopics?: boolean;
}
const { title, description, roadmapId, tnsBannerLink, isUpcoming = false, hasSearch = false, note, hasTopics = false } = Astro.props;
const {
title,
description,
roadmapId,
tnsBannerLink,
isUpcoming = false,
hasSearch = false,
note,
hasTopics = false,
} = Astro.props;
const isRoadmapReady = !isUpcoming;
---
<DownloadPopup />
<SubscribePopup />
<LoginPopup />
<div class='border-b'>
<div class='py-5 sm:py-12 container relative'>
<div class='container relative py-5 sm:py-12'>
<YouTubeAlert />
<div class='mt-0 mb-3 sm:mb-4 sm:mt-4'>
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
<div class='mb-3 mt-0 sm:mb-4 sm:mt-4'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
</h1>
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
</div>
<div class='flex justify-between'>
@@ -44,33 +51,42 @@ const isRoadmapReady = !isUpcoming;
<>
<a
href='/roadmaps'
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
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 Roadmaps'
>
&larr;<span class='hidden sm:inline'>&nbsp;All Roadmaps</span>
</a>
{isRoadmapReady && (
<button
data-popup='download-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
aria-label='Download Roadmap'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Download Roadmap Popup'
>
<Icon icon='download' />
<span class='hidden sm:inline ml-2'>Download</span>
</button>
<>
<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='Download Roadmap'
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</button>
<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'
target='_blank'
href={`/pdfs/roadmaps/${roadmapId}.pdf`}
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</a>
</>
)}
<button
data-popup='subscribe-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
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'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Subscribe Roadmap Popup'
>
<Icon icon='email' />
<span class='ml-2'>Subscribe</span>
@@ -83,7 +99,7 @@ const isRoadmapReady = !isUpcoming;
hasSearch && (
<a
href={`/${roadmapId}`}
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
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 Visual Roadmap'
>
&larr;
@@ -98,7 +114,7 @@ const isRoadmapReady = !isUpcoming;
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
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' />
@@ -110,7 +126,11 @@ const isRoadmapReady = !isUpcoming;
</div>
<!-- Desktop: Roadmap Resources - Alert -->
{hasTopics && <RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />}
{
hasTopics && (
<RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />
)
}
{hasSearch && <TopicSearch />}
</div>

View File

@@ -1,5 +1,5 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
export interface Props {
roadmapId: string;
@@ -9,7 +9,10 @@ export interface Props {
const { roadmapId, tnsBannerLink = '' } = Astro.props;
const hasTNSBanner = !!tnsBannerLink;
const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
const roadmapTitle =
roadmapId === 'devops'
? 'DevOps'
: `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
---
<div
@@ -23,16 +26,13 @@ const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).
>
{
hasTNSBanner && (
<div class='px-2 py-1.5 border-b bg-gray-100 hidden sm:block'>
<div class='hidden border-b bg-gray-100 px-2 py-1.5 sm:block'>
<p class='text-sm'>
Get the latest {roadmapTitle} news from our sister site{' '}
<a
href={tnsBannerLink}
target='_blank'
class='font-semibold underline'
ga-category='PartnerClick'
ga-action='TNS Referral'
ga-label='TNS Referral - Roadmap'
>
TheNewStack.io
</a>
@@ -52,13 +52,16 @@ const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).
]}
>
<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'>New</span>
Resources are here, try clicking nodes
<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 py-1.5 text-sm font-medium rounded-md hover:text-black text-gray-500 px-1'
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>
@@ -66,8 +69,9 @@ const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).
</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 roadmap items for resources or visit{' '}
<a href={`/${roadmapId}/topics`} class='text-blue-700 underline'> resources list</a>.
<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>

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export interface Props {
pageUrl: string;

View File

@@ -1,57 +0,0 @@
---
import type { GAEventType } from '../Analytics/analytics';
import Icon from '../Icon.astro';
export type SponsorType = {
url: string;
title: string;
imageUrl: string;
description: string;
event: GAEventType;
};
export interface Props {
sponsor: SponsorType;
}
const {
sponsor: { title, url, description, imageUrl, event },
} = Astro.props;
---
<script src='./sponsor.js'></script>
<a
href={url}
id='sponsor-ad'
target='_blank'
rel='noopener sponsored nofollow'
ga-category={event?.category}
ga-action={event?.action}
ga-label={event?.label}
class='fixed bottom-[15px] right-[15px] outline-transparent z-50 bg-white max-w-[350px] shadow-lg outline-0 hidden'
>
<button
class='absolute top-1.5 right-1.5 text-gray-300 hover:text-gray-800'
aria-label='Close'
close-sponsor
>
<Icon icon='close' class='h-4' />
</button>
<img src={imageUrl} class='h-[150px] lg:h-[169px]' alt='Sponsor Banner' />
<span class='text-sm flex flex-col justify-between'>
<span class='p-[10px]'>
<span class='font-semibold mb-0.5 block'>{title}</span>
<span class='text-gray-500 block'>{description}</span>
</span>
<span class='sponsor-footer'>Partner Content</span>
</span>
</a>
<script>
document.querySelector('[close-sponsor]')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
document.getElementById('sponsor-ad')?.classList.add('hidden');
});
</script>

View File

@@ -1,9 +0,0 @@
window.setTimeout(() => {
const ad = document.querySelector('#sponsor-ad');
if (!ad) {
return;
}
ad.classList.remove('hidden');
ad.classList.add('flex');
}, 500);

View File

@@ -1,41 +0,0 @@
---
import Popup from './Popup/Popup.astro';
import CaptchaFields from './Captcha/CaptchaFields.astro';
---
<Popup id='subscribe-popup' title='Subscribe' subtitle='Enter your email below to receive updates.'>
<form
action='https://news.roadmap.sh/subscribe'
method='POST'
accept-charset='utf-8'
target='_blank'
captcha-form
>
<input type='hidden' name='gdpr' value='true' />
<input
type='email'
name='email'
required
autofocus
class='w-full rounded-md border text-md py-2.5 px-3 mb-2'
placeholder='Enter your Email'
/>
<CaptchaFields />
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
<input type='hidden' name='subform' value='yes' />
<button
type='submit'
name='submit'
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2'
ga-category='Subscription'
ga-action='Submitted Popup Form'
ga-label='Subscribe Roadmap Popup'
>
Subscribe
</button>
</form>
</Popup>

View File

@@ -0,0 +1,226 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
type ContributionInputProps = {
id: number;
title: string;
link: string;
isLast: boolean;
totalCount: number;
onAdd: () => void;
onRemove: () => void;
onChange: (link: { id: number; title: string; link: string }) => void;
};
function ContributionInput(props: ContributionInputProps) {
const {
isLast,
totalCount,
onAdd,
onRemove,
onChange,
id,
title: defaultTitle,
link: defaultLink,
} = props;
const titleRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState('');
const [title, setTitle] = useState(defaultTitle);
const [link, setLink] = useState(defaultLink);
useEffect(() => {
if (!titleRef?.current) {
return;
}
titleRef.current.focus();
}, []);
useEffect(() => {
onChange({ id, title, link });
}, [title, link]);
const canAddMore = isLast && totalCount < 5;
return (
<div className="relative mb-3 rounded-md border p-3">
<p
className={`mb-1 text-xs uppercase ${
focused === 'title' ? 'text-black' : 'text-gray-400'
}`}
>
Resource Title
</p>
<input
ref={titleRef}
type="text"
required
className="block w-full rounded-md border p-2 text-sm focus:border-gray-400 focus:outline-none"
placeholder="e.g. Introduction to RESTful APIs"
onFocus={() => setFocused('title')}
onBlur={() => setFocused('')}
onChange={(e) => setTitle((e.target as any).value)}
/>
<p
className={`mb-1 mt-3 text-xs uppercase ${
focused === 'link' ? 'text-black' : 'text-gray-400'
}`}
>
Resource Link
</p>
<input
type="url"
required
className="block w-full rounded-md border p-2 text-sm focus:border-gray-400 focus:outline-none"
placeholder="e.g. https://roadmap.sh/guides/some-url"
onFocus={() => setFocused('link')}
onBlur={() => setFocused('')}
onChange={(e) => setLink((e.target as any).value)}
/>
<div className="mb-0 mt-3 flex gap-3">
{totalCount !== 1 && (
<button
onClick={(e) => {
e.preventDefault();
onRemove();
}}
className="rounded-md text-sm font-semibold text-red-500 underline underline-offset-2 hover:text-red-800"
>
- Remove Link
</button>
)}
{canAddMore && (
<button
onClick={(e) => {
e.preventDefault();
onAdd();
}}
className="rounded-md text-sm font-semibold text-gray-600 underline underline-offset-2 hover:text-black"
>
+ Add another Link
</button>
)}
</div>
</div>
);
}
type ContributionFormProps = {
resourceType: string;
resourceId: string;
topicId: string;
onClose: (message?: string) => void;
};
export function ContributionForm(props: ContributionFormProps) {
const { onClose, resourceType, resourceId, topicId } = props;
const [isSubmitting, setIsSubmitting] = useState(false);
const [links, setLinks] = useState<
{ id: number; title: string; link: string }[]
>([
{
id: new Date().getTime(),
title: '',
link: '',
},
]);
async function onSubmit(e: any) {
e.preventDefault();
setIsSubmitting(true);
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-contribute-link`,
{
resourceType,
resourceId,
topicId,
links,
}
);
setIsSubmitting(false);
if (!response || error) {
alert(error?.message || 'Something went wrong. Please try again.');
return;
}
onClose('Thanks for your contribution! We will review it shortly.');
}
return (
<div>
<div className="mb-2 mt-2 rounded-md border bg-gray-100 p-3">
<h1 className="mb-2 text-2xl font-bold">Guidelines</h1>
<ul class="flex flex-col gap-1 text-sm text-gray-700">
<li>Content should only be in English.</li>
<li>Do not add things you have not evaluated personally.</li>
<li>It should strictly be relevant to the topic.</li>
<li>It should not be paid or behind a signup.</li>
<li>
Quality over quantity. Smaller set of quality links is preferred.
</li>
</ul>
</div>
<form onSubmit={onSubmit}>
{links.map((link, counter) => (
<ContributionInput
key={link.id}
id={link.id}
title={link.title}
link={link.link}
isLast={counter === links.length - 1}
totalCount={links.length}
onChange={(newLink) => {
setLinks(
links.map((l) => {
if (l.id === link.id) {
return newLink;
}
return l;
})
);
}}
onRemove={() => {
setLinks(links.filter((l) => l.id !== link.id));
}}
onAdd={() => {
setLinks([
...links,
{
id: new Date().getTime(),
title: '',
link: '',
},
]);
}}
/>
))}
<div className="flex gap-2">
<button
disabled={isSubmitting}
type="submit"
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white hover:bg-black disabled:cursor-not-allowed disabled:bg-gray-400"
>
{isSubmitting ? 'Please wait ...' : 'Submit'}
</button>
<button
className="block w-full rounded-md border border-red-500 p-2 text-sm text-red-600 hover:bg-red-600 hover:text-white"
onClick={(e) => {
e.preventDefault();
onClose();
}}
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,245 @@
import { useMemo, useRef, useState } from 'preact/hooks';
import CloseIcon from '../../icons/close.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { useKeydown } from '../../hooks/use-keydown';
import { useLoadTopic } from '../../hooks/use-load-topic';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useToggleTopic } from '../../hooks/use-toggle-topic';
import { httpGet } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
import {
isTopicDone,
renderTopicProgress,
ResourceType,
updateResourceProgress as updateResourceProgressApi,
} from '../../lib/resource-progress';
import { pageProgressMessage, sponsorHidden } from '../../stores/page';
import { TopicProgressButton } from './TopicProgressButton';
import { ContributionForm } from './ContributionForm';
export function TopicDetail() {
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
const [isActive, setIsActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isContributing, setIsContributing] = useState(false);
const [error, setError] = useState('');
const [topicHtml, setTopicHtml] = useState('');
const isGuest = useMemo(() => !isLoggedIn(), []);
const topicRef = useRef<HTMLDivElement>(null);
// Details of the currently loaded topic
const [topicId, setTopicId] = useState('');
const [resourceId, setResourceId] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
const showLoginPopup = () => {
const popupEl = document.querySelector(`#login-popup`);
if (!popupEl) {
return;
}
popupEl.classList.remove('hidden');
popupEl.classList.add('flex');
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]');
if (focusEl) {
focusEl.focus();
}
};
// Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, () => {
setIsActive(false);
setIsContributing(false);
});
useKeydown('Escape', () => {
setIsActive(false);
setIsContributing(false);
});
// Toggle topic is available even if the component UI is not active
// This is used on the best practice screen where we have the checkboxes
// to mark the topic as done/undone.
useToggleTopic(({ topicId, resourceType, resourceId }) => {
if (isGuest) {
showLoginPopup();
return;
}
pageProgressMessage.set('Updating');
// Toggle the topic status
isTopicDone({ topicId, resourceId, resourceType })
.then((oldIsDone) =>
updateResourceProgressApi(
{
topicId,
resourceId,
resourceType,
},
oldIsDone ? 'pending' : 'done'
)
)
.then(({ done = [] }) => {
renderTopicProgress(
topicId,
done.includes(topicId) ? 'done' : 'pending'
);
})
.catch((err) => {
alert(err.message);
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
});
});
// Load the topic detail when the topic detail is active
useLoadTopic(({ topicId, resourceType, resourceId }) => {
setIsLoading(true);
setIsActive(true);
sponsorHidden.set(true);
setContributionAlertMessage('');
setTopicId(topicId);
setResourceType(resourceType);
setResourceId(resourceId);
const topicPartial = topicId.replaceAll(':', '/');
const topicUrl =
resourceType === 'roadmap'
? `/${resourceId}/${topicPartial}`
: `/best-practices/${resourceId}/${topicPartial}`;
httpGet<string>(
topicUrl,
{},
{
headers: {
Accept: 'text/html',
},
}
)
.then(({ response }) => {
if (!response) {
setError('Topic not found.');
return;
}
// It's full HTML with page body, head etc.
// We only need the inner HTML of the #main-content
const node = new DOMParser().parseFromString(response, 'text/html');
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
setIsLoading(false);
setTopicHtml(topicHtml);
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
});
if (!isActive) {
return null;
}
return (
<div>
<div
ref={topicRef}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex w-full justify-center">
<img
src={SpinnerIcon}
alt="Loading"
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12"
/>
</div>
)}
{!isLoading && isContributing && (
<ContributionForm
resourceType={resourceType}
resourceId={resourceId}
topicId={topicId}
onClose={(message?: string) => {
if (message) {
setContributionAlertMessage(message);
}
setIsContributing(false);
}}
/>
)}
{!isContributing && !isLoading && !error && (
<>
{/* Actions for the topic */}
<div className="mb-2">
<TopicProgressButton
topicId={topicId}
resourceId={resourceId}
resourceType={resourceType}
onShowLoginPopup={showLoginPopup}
onClose={() => {
setIsActive(false);
setIsContributing(false);
}}
/>
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => {
setIsActive(false);
setIsContributing(false);
}}
>
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
</button>
</div>
{/* Topic Content */}
<div
id="topic-content"
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5"
dangerouslySetInnerHTML={{ __html: topicHtml }}
></div>
{/* Contribution */}
<div className="mt-8 flex-1 border-t">
<p class="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
Help others learn by submitting links to learn more about this topic{' '}
</p>
<button
onClick={() => {
if (isGuest) {
setIsActive(false);
showLoginPopup();
return;
}
setIsContributing(true);
}}
disabled={!!contributionAlertMessage}
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
>
{contributionAlertMessage
? contributionAlertMessage
: 'Submit a Link'}
</button>
</div>
</>
)}
</div>
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import DownIcon from '../../icons/down.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { isLoggedIn } from '../../lib/jwt';
import {
ResourceProgressType,
ResourceType,
getTopicStatus,
renderTopicProgress,
updateResourceProgress,
} from '../../lib/resource-progress';
type TopicProgressButtonProps = {
topicId: string;
resourceId: string;
resourceType: ResourceType;
onShowLoginPopup: () => void;
onClose: () => void;
};
const statusColors: Record<ResourceProgressType, string> = {
done: 'bg-green-500',
learning: 'bg-yellow-500',
pending: 'bg-gray-300',
skipped: 'bg-black',
};
export function TopicProgressButton(props: TopicProgressButtonProps) {
const { topicId, resourceId, resourceType, onClose, onShowLoginPopup } =
props;
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
const [progress, setProgress] = useState<ResourceProgressType>('pending');
const [showChangeStatus, setShowChangeStatus] = useState(false);
const changeStatusRef = useRef<HTMLDivElement>(null);
useOutsideClick(changeStatusRef, () => {
setShowChangeStatus(false);
});
const isGuest = useMemo(() => !isLoggedIn(), []);
useEffect(() => {
if (!topicId || !resourceId || !resourceType) {
return;
}
setIsUpdatingProgress(true);
getTopicStatus({ topicId, resourceId, resourceType })
.then((status) => {
setIsUpdatingProgress(false);
setProgress(status);
})
.catch(console.error);
}, [topicId, resourceId, resourceType]);
// Mark as done
useKeydown(
'd',
() => {
if (progress === 'done') {
onClose();
return;
}
handleUpdateResourceProgress('done');
},
[progress]
);
// Mark as learning
useKeydown(
'l',
() => {
if (progress === 'learning') {
onClose();
return;
}
handleUpdateResourceProgress('learning');
},
[progress]
);
// Mark as learning
useKeydown(
's',
() => {
if (progress === 'skipped') {
onClose();
return;
}
handleUpdateResourceProgress('skipped');
},
[progress]
);
// Mark as pending
useKeydown(
'r',
() => {
console.log(progress);
if (progress === 'pending') {
onClose();
return;
}
handleUpdateResourceProgress('pending');
},
[progress]
);
const handleUpdateResourceProgress = (progress: ResourceProgressType) => {
if (isGuest) {
onClose();
onShowLoginPopup();
return;
}
setIsUpdatingProgress(true);
updateResourceProgress(
{
topicId,
resourceId,
resourceType,
},
progress
)
.then(() => {
setProgress(progress);
onClose();
renderTopicProgress(topicId, progress);
})
.catch((err) => {
alert(err.message);
console.error(err);
})
.finally(() => {
setIsUpdatingProgress(false);
});
};
const allowMarkingSkipped = ['pending', 'learning', 'done'].includes(
progress
);
const allowMarkingDone = ['skipped', 'pending', 'learning'].includes(
progress
);
const allowMarkingLearning = ['done', 'skipped', 'pending'].includes(
progress
);
const allowMarkingPending = ['skipped', 'done', 'learning'].includes(
progress
);
if (isUpdatingProgress) {
return (
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
<img alt="Check" class="h-4 w-4 animate-spin" src={SpinnerIcon} />
<span className="ml-2">Updating Status..</span>
</button>
);
}
return (
<div className="relative inline-flex rounded-md border border-gray-300">
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
<span class="flex h-2 w-2">
<span
class={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`}
></span>
</span>
<span className="ml-2 capitalize">
{progress === 'learning' ? 'In Progress' : progress}
</span>
</span>
<button
className="inline-flex cursor-pointer items-center rounded-br-md rounded-tr-md border-l border-l-gray-300 bg-gray-100 p-1 px-2 text-sm text-black hover:bg-gray-200"
onClick={() => setShowChangeStatus(true)}
>
<span className="mr-0.5">Update Status</span>
<img alt="Check" class="h-4 w-4" src={DownIcon} />
</button>
{showChangeStatus && (
<div
className="absolute right-0 top-full mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md"
ref={changeStatusRef!}
>
{allowMarkingDone && (
<button
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
onClick={() => handleUpdateResourceProgress('done')}
>
<span>
<span
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['done']}`}
></span>
Done
</span>
<span class="text-xs text-gray-500">D</span>
</button>
)}
{allowMarkingLearning && (
<button
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
onClick={() => handleUpdateResourceProgress('learning')}
>
<span>
<span
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['learning']}`}
></span>
In Progress
</span>
<span class="text-xs text-gray-500">L</span>
</button>
)}
{allowMarkingPending && (
<button
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
onClick={() => handleUpdateResourceProgress('pending')}
>
<span>
<span
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['pending']}`}
></span>
Reset
</span>
<span class="text-xs text-gray-500">R</span>
</button>
)}
{allowMarkingSkipped && (
<button
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
onClick={() => handleUpdateResourceProgress('skipped')}
>
<span>
<span
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['skipped']}`}
></span>
Skip
</span>
<span class="text-xs text-gray-500">S</span>
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -1,76 +0,0 @@
---
import Icon from '../Icon.astro';
import Loader from '../Loader.astro';
export interface Props {
contentContributionLink: string;
}
const { contentContributionLink } = Astro.props;
---
<div id='topic-overlay' class='hidden'>
<div
class='fixed top-0 right-0 z-40 h-screen p-4 sm:p-6 overflow-y-auto bg-white w-full sm:max-w-[600px]'
tabindex='-1'
id='topic-body'
>
<div id='topic-loader' class='hidden'>
<Loader />
</div>
<div id='topic-actions' class='hidden mb-2'>
<button
id='mark-topic-done'
ga-category='TopicClick'
ga-action='topic/mark-completion'
ga-label='done'
class='bg-green-600 text-white p-1 px-2 text-sm rounded-md hover:bg-green-700 inline-flex items-center'
>
<Icon icon='check' />
<span class='ml-2'>Mark as Done</span>
</button>
<button
id='mark-topic-pending'
ga-category='TopicClick'
ga-action='topic/mark-completion'
ga-label='pending'
class='hidden bg-red-600 text-white p-1 px-2 text-sm rounded-md hover:bg-red-700 inline-flex items-center'
>
<Icon icon='reset' />
<span class='ml-2'>Mark as Pending</span>
</button>
<button
type='button'
id='close-topic'
class='text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center'
>
<Icon icon='close' />
</button>
</div>
<div
id='topic-content'
class='prose prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-quoteless prose-blockquote:font-normal prose-h1:mt-7 prose-h1:mb-2.5 prose-p:mt-0 prose-p:mb-2 prose-li:m-0 prose-li:mb-0.5 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mt-[10px] prose-h3:mb-[5px]'
>
</div>
<p
id='contrib-meta'
class='text-gray-400 text-sm border-t pt-3 mt-10 hidden'
>
We are still working on this page. You can contribute by submitting a
brief description and a few links to learn more about this topic <a
target='_blank'
class='underline text-blue-700'
href={contentContributionLink}>on GitHub repository.</a
>.
</p>
</div>
<div class='bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-30'>
</div>
</div>
<script src="./topic.js" />

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