Compare commits

...

138 Commits

Author SHA1 Message Date
Arik Chakma
ccd72c6e44 Fix Roadmap Share Link 2023-09-30 19:47:20 +06:00
Kamran Ahmed
6d1edb76c7 Fix failing build 2023-09-30 14:28:16 +01:00
Kamran Ahmed
5d57d5baaf Update deployment workflow 2023-09-30 14:25:25 +01:00
Kamran Ahmed
d31d626c61 Update deployment workflow 2023-09-30 14:23:00 +01:00
Kamran Ahmed
71bf34e683 Add personal token 2023-09-30 14:17:47 +01:00
Kamran Ahmed
93a91b1d9b Fix failing build 2023-09-30 14:14:47 +01:00
Kamran Ahmed
18c8bd14b2 Fix failing build 2023-09-30 14:13:06 +01:00
Kamran Ahmed
e34695e334 Fix failing build 2023-09-30 14:03:49 +01:00
Arik Chakma
8310671123 Allow creating custom roadmaps (#4486)
* wip: custom roadmap renderer

* wip: custom roadmap events

* wip: roadmap content

* wip: svg styles

* wip: custom roadmap progress

* Render progress

* Shortcut progress

* Progress Tracking styles

* wip: edit and share button

* fix: disabled the share button

* wip: content links rendering

* Fix progress share

* Replace disabled with `canShare`

* wip: show custom roadmaps

* wip: users all roadmaps

* fix: create roadmap api

* chore: roadmap sidebar icon

* wip: content links

* Update links color

* Create roadmap home

* Create Roadmap button

* Roadmap type

* chore: share progress modal

* wip: share roadmap

* wip: change visibility

* chore: custom roadmap progress in activity

* wip: custom roadmap share progress

* chore: friend's roadmap

* wip: custom roadmap skeleton

* chore: roadmap title

* Restricted Page

* fix: skeleton loading width

* Fix create roadmap button

* chore: remove user id

* chore: pick roadmap and share

* chore: open new tab on create roadmap

* chore: change share title

* chore: use team id from params

* chore: team roadmap create modal

* chore: create team roadmap

* chore: custom roadmap modal

* chore: placeholde roadmaps

* chore: roadmap hint

* chore: visibility label

* chore: public roadmap

* chore: empty screen

* chore: team progress

* chore: create roadmap responsive

* chore: form error

* chore: multi user history

* wip: manage custom roadmap

* chore: empty roadmap list

* chore: custom roadmap visit

* chore: shared roadmaps

* chore: shared roadmaps

* chore: empty screen and topic title

* chore: show progress bar

* Implement Error in topic details

* Add Modal close button

* fix: link groups

* Refactor roadmap creation

* Refactor roadmap creation

* Refactor team creation

* Refactor team roadmaps

* Refactor team creation roadmap selection

* Refactor

* Refactor team roadmap loading

* Refactor team roadmaps

* Refactor team roadmaps listing

* Refactor Account dropdown

* Updates

* Refactor Account dropdown

* Fix Team name overflow

* Change Icon color

* Update team dropdown

* Minor UI fixes

* Fix minor UI

* Flicker fix in team dropdown

* Roadmap action dropdown with responsiveness

* Team roadmaps listing

* Update team settings

* Team roadmaps listing

* fix: remove visibility change

* Update roadmap options modal

* Add dummy renderer

* Add renderer script

* Add generate renderer script

* Add generate renderer

* wip: add share settings

* Update

* Update UI

* Update Minor UI

* Fix team issue

* Update Personal roadmaps UI

* Add Roadmap Secret

* Update teams type

* Rearrange sections

* Change Secret name

* Add action button on roadmap detail page

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-30 13:55:24 +01:00
Kamran Ahmed
d45c8f9cb2 Update coursera links 2023-09-29 17:26:54 +01:00
Kamran Ahmed
573263ed74 Fix back button not working 2023-09-26 21:18:35 +01:00
Kamran Ahmed
f27aa58ac3 Fix back button not working 2023-09-26 21:14:26 +01:00
Saleh Hashemi
518cf4ce73 Fix broken Git Tutorial for Dummies link 2023-09-26 19:18:42 +06:00
Akshay Jagiasi
7bde0b3f44 Add EVM link (#3727) 2023-09-22 20:46:09 +01:00
Lane Wagner
4b6dcb3a37 Add golang course (#3730) 2023-09-22 20:45:29 +01:00
Alyxson Marques
c50200bfe7 Added links to exception handling javascript roadmap (#3775)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:40:06 +01:00
Akshay Jagiasi
5ffb9fad9f Add solana whitepaper (#3781)
* Solana whitepaper added

* Update src/data/roadmaps/blockchain/content/101-blockchain-general-knowledge/109-blockchains/100-solana.md

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:39:06 +01:00
The New Stack
dd7d312aa1 Add TypeScript resources (#3789)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:34:58 +01:00
The New Stack
81447f6b43 Add TypeScript vs JavaScript (#3790) 2023-09-22 20:33:15 +01:00
Valentino Traverso
fe711f498d Fix typos (#3794) 2023-09-22 20:32:50 +01:00
rane gray
c65f12fcb8 Add zustand resource (#3803)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 20:27:37 +01:00
Tim Jonas Meinerzhagen
cab075bf5b Fix salting typo link (#3820) 2023-09-22 20:24:50 +01:00
Benjamin Norval
685021493c Fix typos and wording (#3902)
Improved grammar and wording choices for the file of `choose-image-format-approprietly.md` in Frontend Performance - Best Practices.
2023-09-22 20:24:29 +01:00
Rafael Bicalho
482cf64bf5 Update CORS Resources (#3908)
* Update 106-cors.md

* Update 102-cors.md

* Update 106-cors.md
2023-09-22 20:23:59 +01:00
keeplz
9051e22476 Add event loop resource (#3921)
add a video for event loop, it's js conf in asia 2018
2023-09-22 20:23:30 +01:00
Selva Muthu Kumaran
1b538b399f Fix backend link (#4484)
backend-roadmap-throttling- missed URL fixed
fixes : #4473
2023-09-22 20:22:40 +01:00
アドヴァイス
05673087c5 Remove dead link (#4489)
In the "Rate limiting" section, Blogs and tutorials on RxJS refers to a 404 page.

The dead link has been fixed with the correct and appropriate link.
2023-09-22 20:21:59 +01:00
Orca
5256df9c07 Fix typos (#4500)
- Fixed a typo
- Fixed the title of the freecodecamp link
2023-09-22 20:19:23 +01:00
Orca
ddf8884501 Update 100-builtin-modules.md (#4501)
- Removed a redundant `events` entry
2023-09-22 20:18:26 +01:00
steph
05492b60ee Update blockchain resources (#4502) 2023-09-22 20:18:04 +01:00
Kamran Ahmed
b92ae9b836 Increase line height of question answers 2023-09-22 05:27:06 +01:00
Kamran Ahmed
83df0da6b4 Enable indexing of question pages 2023-09-22 05:22:45 +01:00
Kamran Ahmed
a58b78bfe9 Hide account dropdown when user clicks anywhere 2023-09-22 05:20:28 +01:00
Kamran Ahmed
2fa41f583e Add react questions 2023-09-22 05:15:52 +01:00
Kamran Ahmed
80819f8914 UI fixes for questions 2023-09-22 05:08:24 +01:00
Arik Chakma
edcf0e683d Add react questions (#4492)
* Add more questions

* wip: add lazy, conditional questions

* wip: Add RSC questions

* wip: add component's lifecycle

* wip: add dependency array question

* wip: add comment and state

* chore: add more questions

* wip: add list question

* wip: add directive questions

* fix: conventions and examples

* wip: add custom hook question

* wip: add hydration question

* wip: add error boundary example

* wip: add strict mode question

* wip: investigating slow react app

* Update src/data/question-groups/react/react.md

* Update src/data/question-groups/react/react.md

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-22 05:02:12 +01:00
Arik Chakma
aa6d48b775 Add more questions and remove setState 2023-09-19 07:34:29 +06:00
Kamran Ahmed
3e622ecc2c UI Change for Title Question 2023-09-18 18:27:48 +01:00
Kamran Ahmed
ea5c3c2c01 UI Change for Title Question 2023-09-18 18:25:38 +01:00
Kamran Ahmed
8dc0424823 Update description meta for frontend, backend, devops 2023-09-18 17:39:54 +01:00
Kamran Ahmed
f3b16eb50f Fix headings 2023-09-18 16:23:44 +01:00
Kamran Ahmed
e07112a3a9 Remove duplicate questions 2023-09-18 16:19:39 +01:00
Kamran Ahmed
81983b6b06 Add more questions 2023-09-18 16:15:12 +01:00
Kamran Ahmed
bc6b100c26 Add introductory paragraph on roadmaps 2023-09-16 11:20:23 +01:00
Ihor
846bbc1533 fix(typescript): fix template lineral type definition (#4474) 2023-09-13 21:09:45 +01:00
roadmap bot
0b0168b40f chore: add resource under qa:qa-basics:project-management:atlassian 2023-09-12 17:05:41 +01:00
Matvey Volkov
4c9371ee74 Fix issue in typescript (#3922)
json_build_object is used to create json object and get it
2023-09-12 17:03:57 +01:00
Toshita Singh
bb9cc31e8a Fix typo in prototypal inheritance (#3930)
Completed missing property name used to set the prototype of an object.
2023-09-12 16:44:01 +01:00
Jakub Olszewski
8585857cc3 Add ChangeNotifier and ValueNotifier tutorials (#3997)
Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-12 16:41:36 +01:00
JasonMan34
8c2e812667 Fix recursive types example in typescript roadmap (#4022)
Co-authored-by: Itamar Zwi <itamarz@amplicy.io>
2023-09-12 16:39:19 +01:00
Olawuwo Abideen
bfbee6da0f Add a resource for REST (#4025) 2023-09-12 16:38:47 +01:00
Selva Muthu Kumaran
8057b218a0 Fix video link (#4398)
Computer network | Google IT Support certificate video fixed
fix : #4396
2023-09-12 16:38:10 +01:00
Selva Muthu Kumaran
c3d24a65d1 Fix appium link (#4402)
QA-roadmap-appium website - new link provided
fix: #4205
2023-09-12 16:37:45 +01:00
Tomasz Mikulski
67beb4e8c4 Fix broken http link to presentation - use https (#4405) 2023-09-12 16:37:15 +01:00
Selva Muthu Kumaran
35066d5b70 Fix video lini (#4408)
python-roadmap-oop-classes-python OOP tutorial - fixed video link
fixes : #4221

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-09-12 16:36:59 +01:00
Julien Alric
bb76ae411f Update system-design.json fix typo (#4414) 2023-09-12 16:36:22 +01:00
Mohit Rajput
98ea93da8c fix incorrect url change (#4415) 2023-09-12 16:36:06 +01:00
Selva Muthu Kumaran
a69f0cc1b1 Fix YARP in .net roadmap (#4416)
asp.net-core-YARP-description fixex
fixes : #4406
2023-09-12 16:35:36 +01:00
Michał Gałązka
e50e75479a Fixed PHP official website address in backend roadmap (#4417)
changed from php.org to php.net
2023-09-12 16:35:13 +01:00
FranMD
f4592b1e58 Update URL for "Enabling HTTPS on Your Servers" site (#4418) 2023-09-12 16:34:50 +01:00
Selva Muthu Kumaran
45c88da643 Add information about local scope (#4420)
javascript-roadmap-scope-variable-local scope description added
fixes : #4388
2023-09-12 16:34:32 +01:00
Mikhail Ostashchenko
a54fe0d1ba Fix broken links (#4421) 2023-09-12 16:33:51 +01:00
Leo Wang
e1f494776e Fix content link in contributing.md (#4431)
Co-authored-by: Leo Wang <ab0988956087@gamil.com>
2023-09-12 16:33:08 +01:00
Muhammad Afzal
11272da330 docs: add content for Google Cloud Functions (#4443) 2023-09-12 16:31:40 +01:00
Andret Carpizo
8903f11f02 Fix Template Specialization Index CodeBlock for const in printData (#4446) 2023-09-12 16:31:24 +01:00
Selva Muthu Kumaran
8ca9f976cd python-roadmap-decorators (#4448)
python-roadmap-modules-decorators - new link for python decorators in 1 minute
2023-09-12 16:30:57 +01:00
Blake
488521d2e3 Update URL for OpenID Link (#4459) 2023-09-12 16:30:40 +01:00
Kirill Bryntsev
072953c69a Add information about function pointer (#4460) 2023-09-12 16:30:27 +01:00
Akash Sharma
79a656e171 Fixing PRIMARY_KEY NULL constraint (#4465) 2023-09-12 16:27:19 +01:00
Aus Gomez
b565ce9bce issue-442 (#4470) 2023-09-12 16:26:30 +01:00
Kamran Ahmed
460ea8b95a Fix icon on the team creation page 2023-09-06 17:39:04 +01:00
Kamran Ahmed
26ab7b9098 Remove EKS from devops beginner 2023-09-05 11:25:39 +01:00
Kamran Ahmed
0eebcd03a4 Add questions on homepage 2023-09-03 23:18:00 +01:00
Kamran Ahmed
9c75404d0c feat: responsiveness of questions 2023-09-03 23:12:27 +01:00
Kamran Ahmed
61c3c88fb6 Integrate question backend 2023-09-03 19:57:51 +01:00
Kamran Ahmed
1ed54bad90 Change confetti to show on completion of quiz 2023-09-03 17:07:39 +01:00
Kamran Ahmed
437d879af3 feat: add finished screen for questions 2023-09-03 14:11:56 +01:00
Kamran Ahmed
58dd3f2f41 Fix flickering numbers 2023-09-03 12:17:30 +01:00
Kamran Ahmed
cbe758349c Add reset progress functionality 2023-09-03 12:14:20 +01:00
Kamran Ahmed
a847d0b08d Show user progress 2023-09-03 12:02:34 +01:00
Kamran Ahmed
548b7f31f9 Fix confetti does not show up properly 2023-09-03 11:49:00 +01:00
Kamran Ahmed
2e18d5a563 feat: question page with progress tracking 2023-09-03 03:20:59 +01:00
Kamran Ahmed
5bbcd85e6c Update question ui 2023-09-02 23:09:02 +01:00
Kamran Ahmed
1eb0e8869a fix: broken type on hero 2023-09-02 18:00:58 +01:00
Kamran Ahmed
1b74e86db7 Custom roadmaps listing on homepage 2023-09-02 17:49:07 +01:00
Kamran Ahmed
07b2cb0f9b fix: ui 2023-09-02 02:04:44 +01:00
Kamran Ahmed
fba926625d fix: scroll to top when user hides answer 2023-09-02 01:59:07 +01:00
Kamran Ahmed
e4c29b03ab feat: question page ui 2023-09-02 01:56:06 +01:00
Kamran Ahmed
2a7fd53c8b feat: question page confetti 2023-09-01 20:07:17 +01:00
Kamran Ahmed
4cb905b69a feat: design for question page 2023-09-01 18:58:00 +01:00
Kamran Ahmed
a123fc0828 fix: client:only=react 2023-09-01 17:25:10 +01:00
Kamran Ahmed
e15a36a2ce Fix accessibility issues 2023-09-01 00:04:25 +01:00
Kamran Ahmed
ca32c814da Fix accessibility issues 2023-08-31 23:54:27 +01:00
Kamran Ahmed
c4ef2bfcb4 fix: broken build 2023-08-31 23:23:08 +01:00
Kamran Ahmed
bb42c809fb fix: broken build 2023-08-31 23:21:18 +01:00
Kamran Ahmed
03d0a32fd6 chore: upgrade to astro v3 (#4437) 2023-08-31 23:17:51 +01:00
Kamran Ahmed
b8c90948f9 chore: trigger build 2023-08-31 19:05:54 +01:00
Kamran Ahmed
5c57a84e82 chore: migrate from preact to react (#4435) 2023-08-31 17:19:18 +01:00
Kamran Ahmed
c274feced1 Fix broken build 2023-08-30 18:56:36 +01:00
Kamran Ahmed
cdb9153029 Change in grid item design 2023-08-30 18:51:20 +01:00
Jamie Douglas
d3bebfeea6 Updated link to Gradle site (#4423) 2023-08-30 14:20:28 +01:00
Pawel Janicki
68f9e4576b Fix ArgoCD Beginner tutorial link (#4425)
https://github.com/kamranahmedse/developer-roadmap/issues/4424
2023-08-30 14:20:05 +01:00
Leo Wang
051bcce933 Fix video link in Python OOP classes (#4427)
Co-authored-by: Leo Wang <ab0988956087@gamil.com>
2023-08-30 14:19:40 +01:00
Kamran Ahmed
e3793b00c7 Change background color of grid 2023-08-29 18:35:53 +01:00
Kamran Ahmed
f256a5a9b0 Fix logout not working 2023-08-28 17:51:34 +01:00
Mohit Rajput
7e18c97e78 from inside compilerOptions to outside exclude (#4410) 2023-08-28 11:26:46 +06:00
Selva Muthu Kumaran
c95caccae5 golang\conditional-statement (#4400)
* golang\conditional-statement

golang\conditional-statement\golang programs switch case - new link provided

* Add both conditional statements

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
2023-08-26 22:47:33 +06:00
Juan Pablo Partridge
102c57e925 Add mongodb roadmap in backend content (#4035) 2023-08-26 00:15:25 +01:00
Mikhail Ostashchenko
1ec6005fe1 Fix code style (#4356) 2023-08-25 19:40:58 +01:00
KT
ce41b3a955 Remove duplicate resource (#4375)
Remove resource hosting a duplicate of another listed resource.
2023-08-25 19:40:35 +01:00
Selva Muthu Kumaran
eea79968e2 Fix data type content (#4381)
data type (struct and class) clarification
2023-08-25 19:39:37 +01:00
Selva Muthu Kumaran
538e41307c Fix video link (#4385)
javascript\variable\scope\understanding global local function block scope
2023-08-25 19:39:01 +01:00
Mikhail Ostashchenko
79fcf2400f SFINAE fix (C++) (#4390)
When you try to use these function templates, there can be ambiguity issues. If you call foo with an arithmetic type, both template overloads will be considered valid, and the compiler won't know which one to choose.

You can use specialization or tag dispatching to properly handle different cases.
2023-08-25 19:38:41 +01:00
Tushar Daiya
0da1edaa55 Fix link in python roadmap (#4393) 2023-08-25 19:37:46 +01:00
Kamran Ahmed
b04b8c702f Cookie sharing across sub-domains 2023-08-25 02:19:10 +01:00
Bartłomiej Majowicz - Unlimitech
1a7a6db50c Fix typos (#4362)
* Fix misspell.

* Add missing link title.

* Fix add missing word.
2023-08-22 12:37:32 +01:00
Mikhail Ostashchenko
7072c4cf80 Fix code in c++ (#4373) 2023-08-22 12:19:08 +01:00
Mikhail Ostashchenko
f2b29f80f9 Add Reverse iterator (C++) (#4374) 2023-08-22 12:18:19 +01:00
roadmap bot
76c2686269 chore: add resource under cpp:language-concepts:type-casting 2023-08-20 19:14:11 +01:00
Kamran Ahmed
b7728fa6fd Change related roadmaps + faqs to h2 2023-08-18 18:52:50 +01:00
Kamran Ahmed
ca5bae687b Update AI and Data Scientist roadmap 2023-08-18 14:29:33 +01:00
Kamran Ahmed
face1eefbb Update data scientist roadmap 2023-08-17 19:39:08 +01:00
Kamran Ahmed
498ef2eb3b Change feedback popup design 2023-08-17 17:20:19 +01:00
Kamran Ahmed
80d53a9c5d Add AI and Data Scientist Roadmap 2023-08-17 13:57:42 +01:00
Arik Chakma
e0eccaa30e Add team feedback popup (#4341)
* wip: submit feedback popup

* wip: feedback popup state
2023-08-17 13:45:22 +01:00
Erasmo Hernández
c43ee13c94 Fix typo from bug 4210 (#4211) 2023-08-17 03:01:58 +01:00
Kyrylo Nehaturov
b57c4cb558 fix: removed dublicated link (#4217)
removed dublicated link in react roadmap
2023-08-17 03:01:29 +01:00
Kyrylo Nehaturov
c236bf9bf9 fix: removed duplicate link in react 103-rendering/104-events (#4218)
removed duplicate link in react roadmap section 103-rendering/104-events
2023-08-17 03:01:15 +01:00
Kyrylo Nehaturov
c92c67acc9 fix: added link + rewritten link text react 106-state-management (#4219)
I found more obvious such resources list, with the documentation as  the starting resource and then the link for the repo as the next one
2023-08-17 03:01:01 +01:00
Mirac Seref
bec59ed630 Update 101-functional-components.md (#4233)
Fix link issue

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
2023-08-17 03:00:34 +01:00
Mark Puchala 2/
7f800f2717 Update history-of-javascript.md (#4287)
Fix reference to non-existent comment section.
2023-08-17 02:58:57 +01:00
carmen
e5579ef7d1 fix: removed unavailable youtube link (#4288) 2023-08-17 02:58:25 +01:00
Gary Y
9e5baad85f Update 103-reporting.md (#4298)
The current link leads to an outdated page with a 404.

https://www.ministryoftesting.com/dojo/series/the-testing-planet-2019/lessons/the-art-of-the-bug-report

This is the new updated link.
https://www.ministryoftesting.com/articles/11b82aee?s_id=15465627
2023-08-17 02:56:42 +01:00
Hugo Poças
146022d1ed Removed duplicate sentence (#4325)
removed the sentence:
"JOIN Queries
Absolutely, here’s a brief summary about SQL JOIN Queries:"

It's the same information as the sentence below and it doesn't give the impression it's answering anything.
2023-08-17 02:56:22 +01:00
obvTiger
6af8033764 Remove extra "s" on 102-razor-pages.md (#4329) 2023-08-17 02:56:08 +01:00
Nikola Hristov
e4d6cd9f41 Restores astro-compress (#4339)
* Restores astro-compress

* squash!
2023-08-17 02:55:49 +01:00
Mikhail Ostashchenko
5cff162a94 Change to the correct data type (#4343) 2023-08-17 02:54:56 +01:00
Kamran Ahmed
3b7e5d5ce2 Add AI and Data Scientist roadmap 2023-08-17 02:52:35 +01:00
385 changed files with 26273 additions and 3218 deletions

View File

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

View File

@@ -4,9 +4,9 @@ on:
branches: [ master ]
env:
PUBLIC_API_URL: "https://api.roadmap.sh"
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh"
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PAT: ${{ secrets.PAT }}
CI: true
jobs:
build:
@@ -18,7 +18,9 @@ jobs:
- uses: actions/setup-node@v1
with:
node-version: 18
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
- name: Prepare Draw Repository
run: |
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
- uses: pnpm/action-setup@v2.2.2
with:
version: 7.13.4
@@ -27,6 +29,7 @@ jobs:
pnpm install
- name: Generate meta and build
run: |
npm run generate-renderer
npm run build
touch ./dist/.nojekyll
echo 'roadmap.sh' > ./dist/CNAME

5
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.idea
.temp
# build output
dist/
@@ -27,3 +28,7 @@ pnpm-debug.log*
/playwright/.cache/
tests-examples
*.csv
/renderer/*
!/renderer/index.tsx
!/renderer/renderer.ts

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
auto-install-peers=true

View File

@@ -1,13 +1,14 @@
// https://astro.build/config
import preact from '@astrojs/preact';
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import compress from '@otterlord/astro-compress';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config';
import rehypeExternalLinks from 'rehype-external-links';
import { fileURLToPath } from 'node:url';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
site: 'https://roadmap.sh/',
@@ -31,11 +32,9 @@ export default defineConfig({
'https://cs.fyi',
'https://roadmap.sh',
];
if (whiteListedStarts.some((start) => href.startsWith(start))) {
return [];
}
return 'noopener noreferrer nofollow';
},
},
@@ -46,22 +45,6 @@ export default defineConfig({
format: 'file',
},
integrations: [
{
name: 'client-authenticated',
hooks: {
'astro:config:setup'(options) {
options.addClientDirective({
name: 'authenticated',
entrypoint: fileURLToPath(
new URL(
'./src/directives/client-authenticated.mjs',
import.meta.url
)
),
});
},
},
},
tailwind({
config: {
applyBaseStyles: false,
@@ -72,9 +55,10 @@ export default defineConfig({
serialize: serializeSitemap,
}),
compress({
css: false,
js: false,
HTML: false,
CSS: false,
JavaScript: false,
}),
preact(),
react(),
],
});

View File

@@ -30,11 +30,12 @@ Find [the content directory inside the relevant roadmap](https://github.com/kamr
## Guidelines
- <p><strong>Adding everything available out there is not the goal!</strong><br />
- <p><strong>Adding everything available out there is not the goal!</strong><br />
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn?! There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included. Have you read this book? Can you give a short article?</p>
- <p><strong>Create a Single PR for Content Additions</strong></p>
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./content/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./src/data/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
- Write meaningful commit messages
- Look at the existing issues/pull requests before opening new ones

11028
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"dev": "astro dev --port 3000",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
@@ -18,31 +18,44 @@
"roadmap-content": "node scripts/roadmap-content.cjs",
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
"best-practice-content": "node scripts/best-practice-content.cjs",
"generate-renderer": "sh scripts/generate-renderer.sh",
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/preact": "^2.2.1",
"@astrojs/react": "^3.0.0",
"@astrojs/sitemap": "^1.3.3",
"@astrojs/tailwind": "^3.1.3",
"@astrojs/tailwind": "^5.0.0",
"@fingerprintjs/fingerprintjs": "^3.4.1",
"@nanostores/preact": "^0.5.0",
"astro": "^2.6.6",
"@otterlord/astro-compress": "^1.1.48",
"@nanostores/react": "^0.7.1",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"astro": "^3.0.5",
"astro-compress": "^2.0.8",
"clsx": "^2.0.0",
"dracula-prism": "^2.1.13",
"jose": "^4.14.4",
"js-cookie": "^3.0.5",
"lucide-react": "^0.274.0",
"nanoid": "^4.0.2",
"nanostores": "^0.9.2",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.10.12",
"preact": "^10.15.1",
"prismjs": "^1.29.0",
"react": "^18.0.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.0.0",
"reactflow": "^11.8.3",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwindcss": "^3.3.2"
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3"
},
"devDependencies": {
"@playwright/test": "^1.35.1",
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"@types/prismjs": "^1.26.0",
"csv-parser": "^3.0.0",
"gh-pages": "^5.0.0",
"js-yaml": "^4.1.0",

2053
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -35,7 +35,9 @@ Here is the list of available roadmaps with more being actively worked upon.
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
- [QA Roadmap](https://roadmap.sh/qa)
- [Python Roadmap](https://roadmap.sh/python)
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
- [JavaScript Roadmap](https://roadmap.sh/javascript)
@@ -49,7 +51,6 @@ Here is the list of available roadmaps with more being actively worked upon.
- [GraphQL Roadmap](https://roadmap.sh/graphql)
- [Android Roadmap](https://roadmap.sh/android)
- [Flutter Roadmap](https://roadmap.sh/flutter)
- [Python Roadmap](https://roadmap.sh/python)
- [Go Roadmap](https://roadmap.sh/golang)
- [Java Roadmap](https://roadmap.sh/java)
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)

14
renderer/index.tsx Normal file
View File

@@ -0,0 +1,14 @@
export function Renderer(props: any) {
return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
<p className="mb-4">
Renderer is a private component. If you are a collaborator and have
access to it. Run the following command:
</p>
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
npm run generate-renderer
</code>
</div>
);
}

5
renderer/renderer.ts Normal file
View File

@@ -0,0 +1,5 @@
export function renderFlowJSON(data: any, options?: any) {
console.warn("renderFlowJSON is not implemented");
console.warn("run the following command to generate the renderer:");
console.warn("> npm run generate-renderer");
}

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -e
# ignore cloning if .temp/web-draw already exists
if [ ! -d ".temp/web-draw" ]; then
mkdir -p .temp
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
fi
rm -rf renderer
mkdir renderer
# copy the files at /src/editor/renderer/* to /renderer
# while replacing any existing files
cp -rf .temp/web-draw/src/editor/renderer/* renderer
# Add @ts-nocheck to the top of each ts and tsx file
# so that the typescript compiler doesn't complain
# about the missing types
find renderer -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
if [ -f "$file" ]; then
echo "// @ts-nocheck" > temp
cat "$file" >> temp
mv temp "$file"
echo "Added @ts-nocheck to $file"
fi
done
# ignore the worktree changes for the renderer directory
git update-index --skip-worktree renderer/*

View File

@@ -53,12 +53,12 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
const sortOrder = controlName.match(/^\d+/)?.[0];
// No directory for a group without control name
if (!controlName || !sortOrder) {
if (!controlName || (!sortOrder && !controlName.startsWith('check:'))) {
return;
}
// e.g. testing-your-apps:other-options
const controlNameWithoutSortOrder = controlName.replace(/^\d+-/, '');
const controlNameWithoutSortOrder = controlName.replace(/^\d+-/, '').replace(/^check:/, '');
// e.g. ['testing-your-apps', 'other-options']
const dirParts = controlNameWithoutSortOrder.split(':');

View File

@@ -2,6 +2,7 @@
import AstroIcon from './AstroIcon.astro';
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
import { Map } from 'lucide-react';
export interface Props {
activePageId: string;
@@ -26,10 +27,21 @@ const sidebarLinks = [
href: '/account/friends',
title: 'Friends',
id: 'friends',
isNew: false,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
},
},
{
href: '/account/roadmaps',
title: 'Roadmaps',
id: 'roadmaps',
isNew: true,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
component: Map,
},
},
{
@@ -100,10 +112,16 @@ const sidebarLinks = [
isActive ? 'bg-slate-100' : ''
}`}
>
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.icon.component ? (
<sidebarLink.icon.component
className={`${sidebarLink.icon.classes} mr-2`}
/>
) : (
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
)}
{sidebarLink.title}
</a>
</li>
@@ -136,15 +154,20 @@ const sidebarLinks = [
}`}
>
<span class='flex flex-grow items-center'>
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
{sidebarLink.icon.component ? (
<sidebarLink.icon.component
className={`${sidebarLink.icon.classes} mr-2`}
/>
) : (
<AstroIcon
icon={sidebarLink.icon.glyph}
class={`${sidebarLink.icon.classes} mr-2`}
/>
)}
{sidebarLink.title}
</span>
{sidebarLink.isNew &&
sidebarLink.id !== 'friends' &&
!isActive && (
<span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />

View File

@@ -21,11 +21,11 @@ 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">
<div className="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
<h2 className="text-base sm:text-5xl font-bold">
{count}
</h2>
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
<p className="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
</div>
);
}
@@ -34,8 +34,8 @@ export function ActivityCounters(props: ActivityCountersType) {
const { done, learning, streak } = props;
return (
<div 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">
<div className="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
<div className="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
<ActivityCounter
text={'Topics Completed'}
count={`${done?.total || 0}`}

View File

@@ -1,10 +1,21 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
import { pageProgressMessage } from '../../stores/page';
import { EmptyActivity } from './EmptyActivity';
type ProgressResponse = {
updatedAt: string;
title: string;
id: string;
learning: number;
skipped: number;
done: number;
total: number;
isCustomResource: boolean;
};
export type ActivityResponse = {
done: {
today: number;
@@ -13,24 +24,9 @@ export type ActivityResponse = {
learning: {
today: number;
total: number;
roadmaps: {
title: string;
id: string;
learning: number;
done: number;
total: number;
skipped: number;
updatedAt: string;
}[];
bestPractices: {
title: string;
id: string;
learning: number;
done: number;
skipped: number;
total: number;
updatedAt: string;
}[];
roadmaps: ProgressResponse[];
bestPractices: ProgressResponse[];
customs: ProgressResponse[];
};
streak: {
count: number;
@@ -91,16 +87,16 @@ export function ActivityPage() {
streak={activity?.streak || { count: 0 }}
/>
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<div className="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">
<h2 className="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div class="flex flex-col gap-3">
<div className="flex flex-col gap-3">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
@@ -110,6 +106,8 @@ export function ActivityPage() {
})
.map((roadmap) => (
<ResourceProgress
key={roadmap.id}
isCustomResource={roadmap.isCustomResource}
doneCount={roadmap.done || 0}
learningCount={roadmap.learning || 0}
totalCount={roadmap.total || 0}
@@ -136,6 +134,8 @@ export function ActivityPage() {
})
.map((bestPractice) => (
<ResourceProgress
isCustomResource={bestPractice.isCustomResource}
key={bestPractice.id}
doneCount={bestPractice.done || 0}
totalCount={bestPractice.total || 0}
learningCount={bestPractice.learning || 0}

View File

@@ -2,21 +2,21 @@ import RoadmapIcon from '../../icons/roadmap.svg';
export function EmptyActivity() {
return (
<div class="rounded-md">
<div class="flex flex-col items-center p-7 text-center">
<div className="rounded-md">
<div className="flex flex-col items-center p-7 text-center">
<img
alt="no roadmaps"
src={RoadmapIcon}
class="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
src={RoadmapIcon.src}
className="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>
<h2 className="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">
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>{' '}
or{' '}
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
<a href="/best-practices" className="mt-4 text-blue-500 hover:underline">
Best Practices
</a>{' '}
progress.

View File

@@ -1,8 +1,9 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import { useState } from 'react';
import { getUser } from '../../lib/jwt';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';
@@ -15,14 +16,17 @@ type ResourceProgressType = {
skippedCount: number;
onCleared?: () => void;
showClearButton?: boolean;
isCustomResource: boolean;
};
export function ResourceProgress(props: ResourceProgressType) {
const { showClearButton = true } = props;
const { showClearButton = true, isCustomResource } = props;
const toast = useToast();
const [isClearing, setIsClearing] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const userId = getUser()?.id;
const {
updatedAt,
resourceType,
@@ -52,8 +56,8 @@ export function ResourceProgress(props: ResourceProgressType) {
return;
}
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
setIsClearing(false);
setIsConfirming(false);
@@ -62,11 +66,15 @@ export function ResourceProgress(props: ResourceProgressType) {
}
}
const url =
let url =
resourceType === 'roadmap'
? `/${resourceId}`
: `/best-practices/${resourceId}`;
if (isCustomResource) {
url = `/r?id=${resourceId}`;
}
const totalMarked = doneCount + skippedCount;
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
@@ -112,6 +120,7 @@ export function ResourceProgress(props: ResourceProgressType) {
<ProgressShareButton
resourceType={resourceType}
resourceId={resourceId}
isCustomResource={isCustomResource}
className="text-xs font-normal"
shareIconClassName="w-2.5 h-2.5 stroke-2"
checkIconClassName="w-2.5 h-2.5"

View File

@@ -1,6 +1,6 @@
import { useRef, useState } from 'preact/hooks';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../hooks/use-outside-click';
import { OptionType, SearchSelector } from './SearchSelector';
import { type OptionType, SearchSelector } from './SearchSelector';
import type { PageType } from './CommandMenu/CommandMenu';
import { CheckIcon } from './ReactIcons/CheckIcon';
import { httpPut } from '../lib/http';
@@ -65,15 +65,15 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
)?.title;
return (
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white p-4 shadow"
className="popup-body relative rounded-lg bg-white p-4 shadow"
>
{isLoading && (
<>
<div class="flex items-center justify-center gap-2 py-8">
<div className="flex items-center justify-center gap-2 py-8">
<Spinner isDualRing={false} className="h-4 w-4" />
<h2 className="font-medium">Loading...</h2>
</div>
@@ -82,7 +82,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
{!isLoading && !error && selectedRoadmap && (
<div className={'text-center'}>
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
<h3 class="mb-1.5 text-2xl font-medium">
<h3 className="mb-1.5 text-2xl font-medium">
{selectedRoadmapTitle} Added
</h3>
<p className="mb-4 text-sm leading-none text-gray-400">
@@ -95,11 +95,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
to make changes to the roadmap.
</p>
<div class="flex items-center gap-2">
<div className="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Done
</button>
@@ -110,7 +110,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
setIsLoading(false);
}}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
className="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
>
+ Add More
</button>
@@ -119,14 +119,14 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
)}
{!isLoading && error && (
<>
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
<h3 className="mb-1.5 text-2xl font-medium">Error</h3>
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
<div class="flex items-center gap-2">
<div className="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>
@@ -135,7 +135,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
)}
{!isLoading && !error && !selectedRoadmap && (
<>
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
<h3 className="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
<p className="mb-3 text-sm leading-none text-gray-400">
Search and add a roadmap
</p>
@@ -156,11 +156,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
placeholder={'Search for roadmap'}
/>
<div class="flex items-center gap-2">
<div className="flex items-center gap-2">
<button
onClick={onClose}
type="button"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
const EmailLoginForm: FunctionComponent<{}> = () => {
export function EmailLoginForm() {
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) => {
const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -29,6 +29,7 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.reload();
@@ -76,7 +77,7 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
onInput={(e) => setPassword(String((e.target as any).value))}
/>
<p class="mb-3 mt-2 text-sm text-gray-500">
<p className="mb-3 mt-2 text-sm text-gray-500">
<a
href="/forgot-password"
className="text-blue-800 hover:text-blue-600"
@@ -98,6 +99,4 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
</button>
</form>
);
};
export default EmailLoginForm;
}

View File

@@ -1,8 +1,7 @@
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http';
const EmailSignupForm: FunctionComponent = () => {
export function EmailSignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
@@ -10,7 +9,7 @@ const EmailSignupForm: FunctionComponent = () => {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (e: Event) => {
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
@@ -98,6 +97,4 @@ const EmailSignupForm: FunctionComponent = () => {
</button>
</form>
);
};
export default EmailSignupForm;
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'preact/hooks';
import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http';
export function ForgotPasswordForm() {
@@ -7,7 +7,7 @@ export function ForgotPasswordForm() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: Event) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -29,7 +29,7 @@ export function ForgotPasswordForm() {
};
return (
<form onSubmit={handleSubmit} class="w-full">
<form onSubmit={handleSubmit} className="w-full">
<input
type="email"
name="email"

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import GitHubIcon from '../../icons/github.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import Cookies from 'js-cookie';
@@ -62,6 +61,7 @@ export function GitHubButton(props: GitHubButtonProps) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
@@ -90,10 +90,11 @@ export function GitHubButton(props: GitHubButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
['/respond-invite', '/befriend'].includes(window.location.pathname)
? window.location.pathname + window.location.search
: window.location.pathname;
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
@@ -105,14 +106,14 @@ export function GitHubButton(props: GitHubButtonProps) {
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"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading}
onClick={handleClick}
>
<img
src={icon}
src={icon.src}
alt="GitHub"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with GitHub
</button>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import GoogleIcon from '../../icons/google.svg';
import SpinnerIcon from '../../icons/spinner.svg';
@@ -60,6 +60,7 @@ export function GoogleButton(props: GoogleButtonProps) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
@@ -105,14 +106,14 @@ export function GoogleButton(props: GoogleButtonProps) {
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"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading}
onClick={handleClick}
>
<img
src={icon}
src={icon.src}
alt="Google"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with Google
</button>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import LinkedIn from '../../icons/linkedin.svg';
import SpinnerIcon from '../../icons/spinner.svg';
@@ -60,6 +60,7 @@ export function LinkedInButton(props: LinkedInButtonProps) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
@@ -85,10 +86,11 @@ export function LinkedInButton(props: LinkedInButtonProps) {
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
['/respond-invite', '/befriend'].includes(window.location.pathname)
? window.location.pathname + window.location.search
: window.location.pathname;
const pagePath = ['/respond-invite', '/befriend'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
@@ -105,14 +107,14 @@ export function LinkedInButton(props: LinkedInButtonProps) {
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"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading}
onClick={handleClick}
>
<img
src={icon}
src={icon.src}
alt="Google"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with LinkedIn
</button>

View File

@@ -1,6 +1,6 @@
---
import Popup from '../Popup/Popup.astro';
import EmailLoginForm from './EmailLoginForm';
import { EmailLoginForm } from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
@@ -9,9 +9,9 @@ import { LinkedInButton } from './LinkedInButton';
<Popup id='login-popup' title='' subtitle=''>
<div class='text-center'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
<p class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
Login to your account
</h2>
</p>
<p class='mt-2 text-sm leading-4 text-slate-600'>
You must be logged in to perform this action.
</p>

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'preact/hooks';
import { type FormEvent, useEffect, useState } from 'react';
import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export default function ResetPasswordForm() {
export function ResetPasswordForm() {
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
@@ -21,7 +21,7 @@ export default function ResetPasswordForm() {
}
}, []);
const handleSubmit = async (e: Event) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
@@ -56,6 +56,7 @@ export default function ResetPasswordForm() {
Cookies.set(TOKEN_COOKIE_NAME, token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = '/';
};

View File

@@ -1,10 +1,9 @@
import SpinnerIcon from '../../icons/spinner.svg';
import ErrorIcon from '../../icons/error.svg';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
import ErrorIcon from '../../icons/error.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function TriggerVerifyAccount() {
const [isLoading, setIsLoading] = useState(true);
@@ -30,6 +29,7 @@ export function TriggerVerifyAccount() {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = '/';
})
@@ -58,14 +58,14 @@ export function TriggerVerifyAccount() {
{isLoading && (
<img
alt={'Please wait.'}
src={SpinnerIcon}
class={'mx-auto h-16 w-16 animate-spin'}
src={SpinnerIcon.src}
className={'mx-auto h-16 w-16 animate-spin'}
/>
)}
{error && (
<img
alt={'Please wait.'}
src={ErrorIcon}
src={ErrorIcon.src}
className={'mx-auto h-16 w-16'}
/>
)}
@@ -74,7 +74,7 @@ export function TriggerVerifyAccount() {
</h2>
<div className="text-sm sm:text-base">
{isLoading && <p>Please wait while we verify your account..</p>}
{error && <p class="text-red-700">{error}</p>}
{error && <p className="text-red-700">{error}</p>}
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import VerifyLetterIcon from '../../icons/verify-letter.svg';
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function VerificationEmailMessage() {
@@ -39,13 +39,13 @@ export function VerificationEmailMessage() {
<div className="mx-auto max-w-md text-center">
<img
alt="Verify Email"
src={VerifyLetterIcon}
class="mx-auto mb-4 h-20 w-40 sm:h-40"
src={VerifyLetterIcon.src}
className="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">
<h2 className="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">
<div className="text-sm sm:text-base">
<p>
We have sent you an email at{' '}
<span className="font-bold">{email}</span>. Please click the link to
@@ -53,7 +53,7 @@ export function VerificationEmailMessage() {
soon!
</p>
<hr class="my-4" />
<hr className="my-4" />
{!isEmailResent && (
<>
@@ -72,12 +72,12 @@ export function VerificationEmailMessage() {
</p>
)}
{error && <p class="text-red-700">{error}</p>}
{error && <p className="text-red-700">{error}</p>}
</>
)}
{isEmailResent && (
<p class="text-green-700">Verification email has been sent!</p>
<p className="text-green-700">Verification email has been sent!</p>
)}
</div>
</div>

View File

@@ -36,6 +36,7 @@ function handleGuest() {
'/account/notification',
'/account/update-password',
'/account/settings',
'/account/roadmaps',
'/account/road-card',
'/account/friends',
'/account',

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'preact/hooks';
import { httpDelete, httpGet, httpPatch, httpPost } from '../lib/http';
import { useEffect, useState } from 'react';
import { httpDelete, httpGet, httpPost } from '../lib/http';
import { pageProgressMessage } from '../stores/page';
import { isLoggedIn } from '../lib/jwt';
import { showLoginPopup } from '../lib/popup';
@@ -118,7 +118,7 @@ export function Befriend() {
<ErrorIcon additionalClasses="mx-auto mb-4 mt-24 w-20 opacity-20" />
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
<p class="mb-4 text-base leading-6 text-gray-600">
<p className="mb-4 text-base leading-6 text-gray-600">
{error || 'There was a problem, please try again.'}
</p>
@@ -141,7 +141,7 @@ export function Befriend() {
const isMe = currentUser?.id === user.id;
return (
<div className="container max-w-[400px] text-center">
<div className="container !max-w-[400px] text-center">
<img
alt={'join team'}
src={userAvatar}
@@ -149,13 +149,13 @@ export function Befriend() {
/>
<h2 className={'mb-1 text-3xl font-bold'}>{user.name}</h2>
<p class="mb-6 text-base leading-6 text-gray-600">
<p className="mb-6 text-base leading-6 text-gray-600">
After you add {user.name} as a friend, you will be able to view each
other's skills and progress.
</p>
<div class="mx-auto w-full duration-500 sm:max-w-md">
<div class="flex w-full flex-col items-center gap-2">
<div className="mx-auto w-full duration-500 sm:max-w-md">
<div className="flex w-full flex-col items-center gap-2">
{user.status === 'none' && (
<button
disabled={isMe}
@@ -169,7 +169,7 @@ export function Befriend() {
});
}}
type="button"
class="w-full flex-grow cursor-pointer rounded-lg bg-black px-3 py-2 text-center text-white disabled:cursor-not-allowed disabled:opacity-40"
className="w-full flex-grow cursor-pointer rounded-lg bg-black px-3 py-2 text-center text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{isMe ? "You can't add yourself" : 'Add Friend'}
</button>
@@ -177,7 +177,7 @@ export function Befriend() {
{user.status === 'sent' && (
<>
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
Request Sent
</span>
@@ -188,7 +188,7 @@ export function Befriend() {
setIsConfirming(true);
}}
type="button"
class="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
>
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
Withdraw Request
@@ -196,7 +196,7 @@ export function Befriend() {
)}
{isConfirming && (
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Are you sure?{' '}
<button
className="ml-2 text-red-700 underline"
@@ -225,7 +225,7 @@ export function Befriend() {
{user.status === 'accepted' && (
<>
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<AddedUserIcon additionalClasses="mr-2 h-5 w-5" />
You are friends
</span>
@@ -236,7 +236,7 @@ export function Befriend() {
setIsConfirming(true);
}}
type="button"
class="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
>
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
Remove Friend
@@ -244,7 +244,7 @@ export function Befriend() {
)}
{isConfirming && (
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Are you sure?{' '}
<button
className="ml-2 text-red-700 underline"
@@ -271,12 +271,12 @@ export function Befriend() {
{user.status === 'rejected' && (
<>
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<DeleteUserIcon additionalClasses="mr-2 h-4 w-4" />
Request Rejected
</span>
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Changed your mind?{' '}
<button
className="ml-2 text-red-700 underline"
@@ -296,7 +296,7 @@ export function Befriend() {
{user.status === 'got_rejected' && (
<>
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-500 px-3 py-2 text-center text-red-500">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-500 px-3 py-2 text-center text-red-500">
<StopIcon additionalClasses="mr-2 h-4 w-4" />
Request Rejected
</span>
@@ -311,7 +311,7 @@ export function Befriend() {
pageProgressMessage.set('');
});
}}
class="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-gray-800 bg-gray-800 px-3 py-2 text-center text-white hover:bg-black"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-gray-800 bg-gray-800 px-3 py-2 text-center text-white hover:bg-black"
>
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
Accept Request
@@ -323,7 +323,7 @@ export function Befriend() {
setIsConfirming(true);
}}
type="button"
class="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-white px-3 py-2 text-center text-red-600 hover:bg-red-100"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-white px-3 py-2 text-center text-red-600 hover:bg-red-100"
>
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
Reject Request
@@ -331,7 +331,7 @@ export function Befriend() {
)}
{isConfirming && (
<span class="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Are you sure?{' '}
<button
className="ml-2 text-red-700 underline"

View File

@@ -1,44 +0,0 @@
---
import type { BreadcrumbItem } from '../lib/roadmap-topic';
export interface Props {
breadcrumbs: BreadcrumbItem[];
roadmapId: string;
}
const { breadcrumbs, roadmapId } = Astro.props;
---
<div class='py-7 pb-6'>
<!-- Desktop breadcrumbs -->
<p class='text-gray-500 container hidden sm:block'>
{
breadcrumbs.map((breadcrumb, counter) => {
const isLast = counter === breadcrumbs.length - 1;
if (!isLast) {
return (
<>
<a class='hover:text-gray-800' href={`${breadcrumb.url}`}>
{breadcrumb.title}
</a>
<span>&nbsp;&middot;&nbsp;</span>
</>
);
}
return <span class='text-gray-400'>{breadcrumb.title}</span>;
})
}
</p>
<!-- Mobile breadcrums -->
<p class='container block sm:hidden'>
<a
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
href={`/${roadmapId}`}
>
&larr; Back to Topics List
</a>
</p>
</div>

View File

@@ -1,7 +1,8 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import BestPracticesIcon from '../../icons/best-practices.svg';
import ClipboardIcon from '../../icons/clipboard.svg';
import GuideIcon from '../../icons/guide.svg';
import HomeIcon from '../../icons/home.svg';
import RoadmapIcon from '../../icons/roadmap.svg';
@@ -22,13 +23,13 @@ export type PageType = {
};
const defaultPages: PageType[] = [
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon.src },
{
id: 'account',
url: '/account',
title: 'Account',
group: 'Pages',
icon: UserIcon,
icon: UserIcon.src,
isProtected: true,
},
{
@@ -36,7 +37,7 @@ const defaultPages: PageType[] = [
url: '/team',
title: 'Teams',
group: 'Pages',
icon: GroupIcon,
icon: GroupIcon.src,
isProtected: true,
},
{
@@ -44,28 +45,35 @@ const defaultPages: PageType[] = [
url: '/roadmaps',
title: 'Roadmaps',
group: 'Pages',
icon: RoadmapIcon,
icon: RoadmapIcon.src,
},
{
id: 'best-practices',
url: '/best-practices',
title: 'Best Practices',
group: 'Pages',
icon: BestPracticesIcon,
icon: BestPracticesIcon.src,
},
{
id: 'questions',
url: '/questions',
title: 'Questions',
group: 'Pages',
icon: ClipboardIcon.src,
},
{
id: 'guides',
url: '/guides',
title: 'Guides',
group: 'Pages',
icon: GuideIcon,
icon: GuideIcon.src,
},
{
id: 'videos',
url: '/videos',
title: 'Videos',
group: 'Pages',
icon: VideoIcon,
icon: VideoIcon.src,
},
];
@@ -158,12 +166,12 @@ export function CommandMenu() {
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
<input
ref={inputRef}
autofocus={true}
autoFocus={true}
type="text"
value={searchedText}
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
placeholder="Search roadmaps, guides or pages .."
autocomplete="off"
autoComplete="off"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
setSearchedText(value);
@@ -191,39 +199,43 @@ export function CommandMenu() {
}}
/>
<div class="px-2 py-2">
<div className="px-2 py-2">
<div className="flex flex-col">
{searchResults.length === 0 && (
<div class="p-5 text-center text-sm text-gray-400">
<div className="p-5 text-center text-sm text-gray-400">
No results found
</div>
)}
{searchResults.map((page, counter) => {
{searchResults.map((page: PageType, counter: number) => {
const prevPage = searchResults[counter - 1];
const groupChanged = prevPage && prevPage.group !== page.group;
return (
<>
<Fragment key={page.id}>
{groupChanged && (
<div class="border-b border-gray-100"></div>
<div className="border-b border-gray-100"></div>
)}
<a
class={`flex w-full items-center rounded p-2 text-sm ${
className={`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>
<span className="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && (
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
<img
alt={page.title}
src={page.icon}
className="mr-2 h-4 w-4"
/>
)}
{page.title}
</a>
</>
</Fragment>
);
})}
</div>

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import ReactConfetti from 'react-confetti';
type ConfettiPosition = {
x: number;
y: number;
w: number;
h: number;
};
type ConfettiProps = {
pieces?: number;
element?: HTMLElement | null;
onDone?: () => void;
};
export function Confetti(props: ConfettiProps) {
const { element = document.body, onDone = () => null, pieces = 40 } = props;
const [confettiPos, setConfettiPos] = useState<
undefined | ConfettiPosition
>();
function populateConfettiPosition(element: HTMLElement) {
const elRect = element.getBoundingClientRect();
// set confetti position, keeping in mind the scroll values
setConfettiPos({
x: elRect?.x || 0,
y: (elRect?.y || 0) + window.scrollY,
w: elRect?.width || 0,
h: elRect?.height || 0,
});
}
useEffect(() => {
if (!element) {
setConfettiPos(undefined);
return;
}
populateConfettiPosition(element);
}, [element]);
if (!confettiPos) {
return null;
}
return (
<ReactConfetti
height={document.body.scrollHeight}
numberOfPieces={pieces}
recycle={false}
onConfettiComplete={(confettiInstance) => {
setConfettiPos(undefined);
onDone();
}}
initialVelocityX={4}
initialVelocityY={8}
tweenDuration={10}
confettiSource={{
x: confettiPos.x,
y: confettiPos.y,
w: confettiPos.w,
h: confettiPos.h,
}}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import { Stepper } from '../Stepper';
import { Step0, ValidTeamType } from './Step0';
import { Step1, ValidTeamSize } from './Step1';
import { Step0, type ValidTeamType } from './Step0';
import { Step1, type ValidTeamSize } from './Step1';
import { Step2 } from './Step2';
import { httpGet } from '../../lib/http';
import { getUrlParams, setUrlParams } from '../../lib/browser';

View File

@@ -21,7 +21,7 @@ export function NextButton(props: NextButtonProps) {
return (
<button
type={type}
type={type as any}
onClick={onClick}
disabled={isLoading}
className={

View File

@@ -39,7 +39,7 @@ export function NotDropdown(props: NotDropdownProps) {
<img
alt={singularName}
src={ChevronDownIcon}
src={ChevronDownIcon.src}
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
/>
</div>

View File

@@ -1,37 +1,52 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import { httpGet, httpPut } from '../../lib/http';
import type { PageType } from '../CommandMenu/CommandMenu';
import ChevronDownIcon from '../../icons/chevron-down.svg';
import { pageProgressMessage } from '../../stores/page';
import type { TeamDocument } from './CreateTeamForm';
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
import { SelectRoadmapModal } from './SelectRoadmapModal';
import { NotDropdown } from './NotDropdown';
import { Map, Shapes } from 'lucide-react';
import type {
AllowedRoadmapVisibility,
RoadmapDocument,
} from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { useToast } from '../../hooks/use-toast';
export type TeamResourceConfig = {
isCustomResource: boolean;
title: string;
visibility?: AllowedRoadmapVisibility;
resourceId: string;
resourceType: string;
removed: string[];
topics?: number;
sharedTeamMemberIds: string[];
sharedFriendIds: string[];
}[];
type RoadmapSelectorProps = {
teamId: string;
teamResourceConfig: TeamResourceConfig;
setTeamResourceConfig: (config: TeamResourceConfig) => void;
teamResources: TeamResourceConfig;
setTeamResources: (config: TeamResourceConfig) => void;
};
export function RoadmapSelector(props: RoadmapSelectorProps) {
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
const { teamId, teamResources = [], setTeamResources } = props;
const toast = useToast();
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState<boolean>(false);
const [error, setError] = useState<string>('');
async function loadAllRoadmaps() {
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
if (error) {
toast.error(error.message || 'Something went wrong. Please try again!');
setError(error.message || 'Something went wrong. Please try again!');
return;
}
@@ -72,7 +87,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
return;
}
setTeamResourceConfig(response);
setTeamResources(response);
}
async function onRemove(resourceId: string) {
@@ -106,13 +121,25 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
return;
}
setTeamResourceConfig(response);
setTeamResources(response);
}
useEffect(() => {
loadAllRoadmaps().finally();
loadAllRoadmaps().finally(() => {});
}, []);
function handleCustomRoadmapCreated(roadmap: RoadmapDocument) {
const { _id: roadmapId } = roadmap;
if (!roadmapId) {
return;
}
loadAllRoadmaps().finally(() => {});
addTeamResource(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}
return (
<div>
{changingRoadmapId && (
@@ -121,9 +148,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
resourceId={changingRoadmapId}
resourceType={'roadmap'}
teamId={teamId}
setTeamResourceConfig={setTeamResourceConfig}
setTeamResourceConfig={setTeamResources}
defaultRemovedItems={
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
teamResources.find((c) => c.resourceId === changingRoadmapId)
?.removed || []
}
/>
@@ -131,7 +158,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
{showSelectRoadmapModal && (
<SelectRoadmapModal
onClose={() => setShowSelectRoadmapModal(false)}
teamResourceConfig={teamResourceConfig}
teamResourceConfig={teamResources}
allRoadmaps={allRoadmaps}
teamId={teamId}
onRoadmapAdd={(roadmapId) => {
@@ -145,72 +172,170 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
/>
)}
<div className="mt-3">
<NotDropdown
<div className="my-3 flex items-center gap-4">
{isCreatingRoadmap && (
<CreateRoadmapModal
teamId={teamId}
onClose={() => setIsCreatingRoadmap(false)}
onCreated={(roadmap: RoadmapDocument) => {
handleCustomRoadmapCreated(roadmap);
setIsCreatingRoadmap(false);
}}
/>
)}
<button
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
onClick={() => {
setShowSelectRoadmapModal(true);
}}
selectedCount={teamResourceConfig.length}
singularName={'roadmap'}
pluralName={'roadmaps'}
/>
>
<Map className="h-4 w-4 stroke-[2.5]" />
Pick from our roadmaps
</button>
<span className="text-base text-gray-400">or</span>
<button
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
onClick={() => {
setIsCreatingRoadmap(true);
}}
>
<Shapes className="h-4 w-4 stroke-[2.5]" />
Create Custom Roadmap
</button>
</div>
{!teamResourceConfig.length && (
<p className={'mb-3 mt-2 text-base text-gray-400'}>
No roadmaps selected.
</p>
{!teamResources.length && (
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-lg border">
<Map className="mb-2 h-12 w-12 text-gray-300" />
<p className={'text-lg font-semibold'}>No roadmaps selected.</p>
<p className={'text-base text-gray-400'}>
Pick from{' '}
<span
onClick={() => setShowSelectRoadmapModal(true)}
className="cursor-pointer underline"
>
our roadmaps
</span>{' '}
or{' '}
<span
onClick={() => {
setIsCreatingRoadmap(true);
}}
className="cursor-pointer underline"
>
create a new one
</span>
.
</p>
</div>
)}
{teamResourceConfig.length > 0 && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
const roadmapTitle =
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
'...';
{teamResources.length > 0 && (
<div className="mb-3 grid grid-cols-1 flex-wrap gap-2.5 sm:grid-cols-3">
{teamResources.map(
({
isCustomResource,
title: roadmapTitle,
resourceId,
removed: removedTopics,
topics,
}) => {
return (
<div
className="relative flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
key={resourceId}
>
<div className={'w-full flex-grow px-3 pb-2 pt-4'}>
<span className="mb-0.5 block text-base font-medium leading-snug text-black">
{roadmapTitle}
</span>
{removedTopics.length > 0 || (topics && topics > 0) ? (
<span className={'text-xs leading-none text-gray-400'}>
{isCustomResource ? (
<>
Custom &middot; {topics} topic
{topics && topics > 1 ? 's' : ''}
</>
) : (
<>
{removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed
</>
)}
</span>
) : (
<span className="text-xs italic leading-none text-gray-400/60">
{isCustomResource
? 'Placeholder roadmap.'
: 'No changes made ..'}
</span>
)}
</div>
return (
<div className="flex flex-col items-start rounded-md border border-gray-300">
<div className={'w-full px-3 pb-2 pt-4'}>
<span className="mb-0.5 block text-base font-medium leading-none text-black">
{roadmapTitle}
</span>
{removedTopics.length > 0 ? (
<span className={'text-xs leading-none text-gray-900'}>
{removedTopics.length} topic
{removedTopics.length > 1 ? 's' : ''} removed
</span>
) : (
<span className="text-xs italic leading-none text-gray-400/60">
No changes made ..
</span>
{removingRoadmapId === resourceId && (
<div
className={
'flex w-full items-center justify-end p-3 text-sm'
}
>
<span className="text-xs text-gray-500">
Are you sure?{' '}
<button
onClick={() => onRemove(resourceId)}
className="mx-0.5 text-red-500 underline underline-offset-1"
>
Yes
</button>{' '}
<button
onClick={() => setRemovingRoadmapId('')}
className="text-red-500 underline underline-offset-1"
>
No
</button>
</span>
</div>
)}
{(!removingRoadmapId || removingRoadmapId !== resourceId) && (
<div className={'flex w-full justify-between p-3'}>
<button
type="button"
className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
}
onClick={() => {
if (isCustomResource) {
window.open(
`${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${resourceId}`,
'_blank'
);
return;
}
setChangingRoadmapId(resourceId);
}}
>
Customize
</button>
<button
type="button"
className={
'text-xs text-red-500 underline hover:text-black'
}
onClick={() => setRemovingRoadmapId(resourceId)}
>
Remove
</button>
</div>
)}
</div>
<div className={'flex w-full justify-between p-3'}>
<button
type="button"
className={
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
}
onClick={() => setChangingRoadmapId(resourceId)}
>
Customize
</button>
<button
type="button"
className={
'text-xs text-red-500 underline hover:text-black'
}
onClick={() => onRemove(resourceId)}
>
Remove
</button>
</div>
</div>
);
})}
);
}
)}
</div>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
import { useRef, useState } from 'preact/hooks';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
const allowedRoles = [

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import type { PageType } from '../CommandMenu/CommandMenu';
@@ -68,19 +68,19 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
);
return (
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div class="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
<div
ref={popupBodyEl}
class="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
className="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
>
<button
type="button"
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
onClick={onClose}
>
<img alt={'close'} src={CloseIcon} className="h-4 w-4" />
<span class="sr-only">Close modal</span>
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
<span className="sr-only">Close modal</span>
</button>
<input
ref={searchInputEl}
@@ -100,12 +100,13 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
{roleBasedRoadmaps.length > 0 && (
<div className="mb-5 flex flex-wrap items-center gap-2">
{roleBasedRoadmaps.map((roadmap) => {
const isSelected = !!teamResourceConfig.find(
const isSelected = !!teamResourceConfig?.find(
(r) => r.resourceId === roadmap.id
);
return (
<SelectRoadmapModalItem
key={roadmap.id}
title={roadmap.title}
isSelected={isSelected}
onClick={() => {
@@ -131,6 +132,7 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
return (
<SelectRoadmapModalItem
key={roadmap.id}
title={roadmap.title}
isSelected={isSelected}
onClick={() => {

View File

@@ -2,21 +2,23 @@ import BuildingIcon from '../../icons/building.svg';
import UsersIcon from '../../icons/users.svg';
import type { TeamDocument } from './CreateTeamForm';
import { httpPut } from '../../lib/http';
import { useState } from 'preact/hooks';
import { useState } from 'react';
import { NextButton } from './NextButton';
export const validTeamTypes = [
{
value: 'company',
label: 'Company',
icon: BuildingIcon,
description: 'Track the skills and learning progress of the tech team at your company',
icon: BuildingIcon.src,
description:
'Track the skills and learning progress of the tech team at your company',
},
{
value: 'study_group',
label: 'Study Group',
icon: UsersIcon,
description: 'Invite your friends or course-mates and track your learning progress together',
icon: UsersIcon.src,
description:
'Invite your friends or course-mates and track your learning progress together',
},
] as const;
@@ -70,10 +72,11 @@ export function Step0(props: Step0Props) {
return (
<>
<div className={'flex flex-col sm:flex-row gap-3'}>
<div className={'flex flex-col gap-3 sm:flex-row'}>
{validTeamTypes.map((validTeamType) => (
<button
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
key={validTeamType.value}
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pt-12 pb-10 ${
validTeamType.value == selectedTeamType
? 'border-gray-400 bg-gray-100'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
@@ -81,6 +84,7 @@ export function Step0(props: Step0Props) {
onClick={() => setSelectedTeamType(validTeamType.value)}
>
<img
key={validTeamType.value}
alt={validTeamType.label}
src={validTeamType.icon}
className={`mb-3 h-12 w-12 opacity-10 ${
@@ -90,7 +94,7 @@ export function Step0(props: Step0Props) {
<span className="mb-2 block text-2xl font-bold">
{validTeamType.label}
</span>
<span className="text-sm text-gray-500 leading-[21px]">
<span className="text-sm leading-[21px] text-gray-500">
{validTeamType.description}
</span>
</button>
@@ -100,11 +104,11 @@ export function Step0(props: Step0Props) {
{/*Error message*/}
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
<a
href="/account"
className={
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500 text-center'
'rounded-md border border-red-400 bg-white px-8 py-2 text-center text-red-500'
}
>
Cancel

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { AppError, httpPost, httpPut } from '../../lib/http';
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { type AppError, httpPost, httpPut } from '../../lib/http';
import type { ValidTeamType } from './Step0';
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
@@ -49,7 +49,7 @@ export function Step1(props: Step1Props) {
team?.teamSize || ('' as any)
);
const handleSubmit = async (e: Event) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
if (!name || !selectedTeamType) {
@@ -124,7 +124,7 @@ export function Step1(props: Step1Props) {
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
for="name"
htmlFor="name"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
@@ -133,7 +133,7 @@ export function Step1(props: Step1Props) {
type="text"
name="name"
ref={nameRef as any}
autofocus={true}
autoFocus={true}
id="name"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Roadmap Inc."
@@ -147,7 +147,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
for="website"
htmlFor="website"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Website
@@ -168,7 +168,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
<label htmlFor="website" className="text-sm leading-none text-slate-500">
Company LinkedIn URL
</label>
<input
@@ -187,7 +187,7 @@ export function Step1(props: Step1Props) {
)}
<div className="mt-4 flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
<label htmlFor="website" className="text-sm leading-none text-slate-500">
GitHub Organization URL
</label>
<input
@@ -205,7 +205,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
for="team-size"
htmlFor="team-size"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Tech Team Size
@@ -221,11 +221,11 @@ export function Step1(props: Step1Props) {
setTeamSize((e.target as HTMLSelectElement).value as any)
}
>
<option value="" selected>
<option value="">
Select team size
</option>
{validTeamSizes.map((size) => (
<option value={size}>{size} people</option>
<option key={size} value={size}>{size} people</option>
))}
</select>
</div>
@@ -237,7 +237,7 @@ export function Step1(props: Step1Props) {
</div>
)}
<div className="mt-4 flex flex-col md:flex-row items-center justify-between gap-2">
<div className="mt-4 flex flex-col items-center justify-between gap-2 md:flex-row">
<button
type="button"
onClick={onBack}

View File

@@ -1,4 +1,4 @@
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
import { RoadmapSelector, type TeamResourceConfig } from './RoadmapSelector';
import type { TeamDocument } from './CreateTeamForm';
type Step2Props = {
@@ -17,7 +17,9 @@ export function Step2(props: Step2Props) {
<>
<div className="mt-4 flex w-full flex-col">
<div className="mb-1 mt-2">
<h2 className="mb-1 md:mb-1.5 text-lg md:text-2xl font-bold">Select Roadmaps</h2>
<h2 className="mb-1 text-lg font-bold md:mb-1.5 md:text-2xl">
Select Roadmaps
</h2>
<p className="text-sm text-gray-700">
You can always add and customize your roadmaps later.
</p>
@@ -25,12 +27,12 @@ export function Step2(props: Step2Props) {
<RoadmapSelector
teamId={team._id!}
teamResourceConfig={teamResourceConfig}
setTeamResourceConfig={setTeamResourceConfig}
teamResources={teamResourceConfig}
setTeamResources={setTeamResourceConfig}
/>
</div>
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
<button
type="button"
onClick={onBack}
@@ -46,8 +48,9 @@ export function Step2(props: Step2Props) {
<button
type="button"
onClick={onNext}
disabled={teamResourceConfig.length !== 0}
className={
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto'
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto disabled:opacity-50 disabled:pointer-events-none'
}
>
Skip for Now

View File

@@ -1,8 +1,8 @@
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
import { useEffect, useRef, useState } from 'preact/hooks';
import { type AllowedRoles, RoleDropdown } from './RoleDropdown';
import { useEffect, useRef, useState } from 'react';
import { httpPost } from '../../lib/http';
type Step3Props = {
@@ -75,9 +75,9 @@ export function Step3(props: Step3Props) {
return (
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
<div class="mb-1 mt-2">
<h2 class="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
<p class="text-sm text-gray-700">
<div className="mb-1 mt-2">
<h2 className="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
<p className="text-sm text-gray-700">
Use the form below to invite your team members to your team. You can
also invite them later.
</p>
@@ -88,7 +88,7 @@ export function Step3(props: Step3Props) {
<div className="flex flex-col sm:flex-row gap-2" key={user.id}>
<input
ref={userCounter === users.length - 1 ? emailInputRef : null}
autofocus={true}
autoFocus={true}
type="email"
name="email"
required
@@ -178,8 +178,9 @@ export function Step3(props: Step3Props) {
<button
type="button"
onClick={onNext}
disabled={users.filter((u) => u.email).length !== 0}
className={
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black disabled:opacity-50 disabled:pointer-events-none'
}
>
Skip for Now

View File

@@ -8,15 +8,15 @@ type Step4Props = {
export function Step4({ team }: Step4Props) {
return (
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
<div class="mb-1 flex flex-col items-center">
<div className="mb-1 flex flex-col items-center">
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
<h2 class="mb-2 text-2xl font-bold">Team Created</h2>
<p class="text-sm text-gray-700">
<h2 className="mb-2 text-2xl font-bold">Team Created</h2>
<p className="text-sm text-gray-700">
Your team has been created. Happy learning!
</p>
<a
href={`/team/progress?t=${team._id}`}
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
className="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
>
View Team
</a>

View File

@@ -1,15 +1,13 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'react';
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { Spinner } from '../ReactIcons/Spinner';
import { httpGet, httpPut } from '../../lib/http';
import { httpPut } from '../../lib/http';
import { renderTopicProgress } from '../../lib/resource-progress';
import '../FrameRenderer/FrameRenderer.css';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamResourceConfig } from './RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/preact';
import { $currentTeam } from '../../stores/team';
export type ProgressMapProps = {
teamId: string;
@@ -40,8 +38,6 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
const [removedItems, setRemovedItems] =
useState<string[]>(defaultRemovedItems);
const currentTeam = useStore($currentTeam);
useEffect(() => {
function onTopicClick(e: any) {
const groupEl = e.target.closest('.clickable-group');
@@ -69,7 +65,9 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
};
}, [removedItems]);
let resourceJsonUrl = 'https://roadmap.sh';
let resourceJsonUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
if (resourceType === 'roadmap') {
resourceJsonUrl += `/${resourceId}.json`;
} else {
@@ -148,16 +146,12 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
}, []);
return (
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div
id={
currentTeam?.type === 'company'
? 'customized-roadmap'
: 'original-roadmap'
}
id={'customized-roadmap'}
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white shadow"
className="popup-body relative rounded-lg bg-white shadow"
>
<div
className={
@@ -201,7 +195,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
<div id="resource-svg-wrap" ref={containerEl} className="px-4"></div>
{isLoading && (
<div class="flex w-full justify-center">
<div className="flex w-full justify-center">
<Spinner
isDualRing={false}
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"

View File

@@ -0,0 +1,53 @@
import { Plus } from 'lucide-react';
import { isLoggedIn } from '../../../lib/jwt';
import { showLoginPopup } from '../../../lib/popup';
import { cn } from '../../../lib/classname';
import {
type AllowedCustomRoadmapType,
type AllowedRoadmapVisibility,
CreateRoadmapModal,
} from './CreateRoadmapModal';
import { useState } from 'react';
type CreateRoadmapButtonProps = {
className?: string;
type?: AllowedCustomRoadmapType;
};
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
const { className, type } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
function toggleCreateRoadmapHandler() {
if (!isLoggedIn()) {
return showLoginPopup();
}
setIsCreatingRoadmap(true);
}
return (
<>
{isCreatingRoadmap && (
<CreateRoadmapModal
type={type}
onClose={() => {
setIsCreatingRoadmap(false);
}}
/>
)}
<button
className={cn(
'flex h-full w-full items-center justify-center gap-1 overflow-hidden rounded-md border border-dashed border-gray-800 p-3 text-sm text-gray-400 hover:border-gray-600 hover:bg-gray-900 hover:text-gray-300',
className
)}
onClick={toggleCreateRoadmapHandler}
>
<Plus size={16} />
Create a new roadmap
</button>
</>
);
}

View File

@@ -0,0 +1,275 @@
import {
type FormEvent,
type MouseEvent,
useEffect,
useRef,
useState,
} from 'react';
import { Loader2 } from 'lucide-react';
import { Modal } from '../../Modal';
import { useToast } from '../../../hooks/use-toast';
import { httpPost } from '../../../lib/http';
import { cn } from '../../../lib/classname';
import { allowedVisibilityLabels } from '../ShareRoadmapModal';
export const allowedRoadmapVisibility = [
'me',
'friends',
'team',
'public',
] as const;
export type AllowedRoadmapVisibility =
(typeof allowedRoadmapVisibility)[number];
export const allowedCustomRoadmapType = ['role', 'skill'] as const;
export type AllowedCustomRoadmapType =
(typeof allowedCustomRoadmapType)[number];
export interface RoadmapDocument {
_id?: string;
title: string;
description?: string;
creatorId: string;
teamId?: string;
type: AllowedCustomRoadmapType;
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
sharedTeamMemberIds?: string[];
nodes: any[];
edges: any[];
createdAt: Date;
updatedAt: Date;
canManage: boolean;
isCustomResource: boolean;
}
interface CreateRoadmapModalProps {
onClose: () => void;
onCreated?: (roadmap: RoadmapDocument) => void;
teamId?: string;
type?: AllowedCustomRoadmapType;
visibility?: AllowedRoadmapVisibility;
}
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
const { onClose, onCreated, teamId, type: defaultType = 'role' } = props;
const titleRef = useRef<HTMLInputElement>(null);
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState<AllowedCustomRoadmapType>(defaultType);
const isInvalidDescription = description?.trim().length > 80;
async function handleSubmit(
e: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement>,
redirect: boolean = true
) {
e.preventDefault();
if (isLoading) {
return;
}
if (title.trim() === '' || isInvalidDescription || !type) {
toast.error('Please fill all the fields');
return;
}
setIsLoading(true);
const { response, error } = await httpPost<RoadmapDocument>(
`${import.meta.env.PUBLIC_API_URL}/v1-create-roadmap`,
{
title,
description,
type,
...(teamId && {
teamId,
}),
nodes: [],
edges: [],
}
);
if (error) {
setIsLoading(false);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
toast.success('Roadmap created successfully');
if (redirect) {
window.location.href = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
response?._id
}`;
return;
}
if (onCreated) {
onCreated(response as RoadmapDocument);
return;
}
onClose();
setTitle('');
setDescription('');
setType('role');
setIsLoading(false);
}
useEffect(() => {
titleRef.current?.focus();
}, []);
return (
<Modal
onClose={onClose}
bodyClassName="p-4"
wrapperClassName={cn(teamId && 'max-w-lg')}
>
<div className="mb-4">
<h2 className="text-lg font-medium text-gray-900">Create Roadmap</h2>
<p className="mt-1 text-sm text-gray-500">
Add a title and description to your roadmap.
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="mt-4">
<label
htmlFor="title"
className="block text-xs uppercase text-gray-400"
>
Roadmap Title
</label>
<div className="mt-1">
<input
ref={titleRef}
type="text"
name="title"
id="title"
required
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
placeholder="Enter Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</div>
<div className="mt-4">
<label
htmlFor="description"
className="block text-xs uppercase text-gray-400"
>
Description
</label>
<div className="relative mt-1">
<textarea
id="description"
name="description"
required
className={cn(
'block h-24 w-full resize-none rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm',
isInvalidDescription && 'border-red-300 bg-red-100'
)}
placeholder="Enter Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
{description.length}/80
</div>
</div>
</div>
<div className="mt-4">
<label
htmlFor="type"
className="block text-xs uppercase text-gray-400"
>
Type
</label>
<div className="mt-1">
<select
id="type"
name="type"
required
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
value={type}
onChange={(e) =>
setType(e.target.value as AllowedCustomRoadmapType)
}
>
{allowedCustomRoadmapType.map((type) => (
<option key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1)} Based Roadmap
</option>
))}
</select>
</div>
</div>
<div
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')}
>
<button
onClick={onClose}
type="button"
className={cn(
'block h-9 rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100',
!teamId && 'w-full'
)}
>
Cancel
</button>
<div className={cn('flex items-center gap-2', !teamId && 'w-full')}>
{teamId && !isLoading && (
<button
disabled={isLoading}
type="button"
onClick={(e) => handleSubmit(e, false)}
className="flex h-9 items-center justify-center rounded-md border border-black bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:bg-black hover:text-white focus:bg-black focus:text-white"
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : (
'Save as Placeholder'
)}
</button>
)}
<button
disabled={isLoading}
type="submit"
className={cn(
'flex h-9 items-center justify-center rounded-md border border-transparent bg-black px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800',
teamId ? 'hidden sm:flex' : 'w-full'
)}
>
{isLoading ? (
<Loader2 size={16} className="animate-spin" />
) : teamId ? (
'Continue to Editor'
) : (
'Create'
)}
</button>
</div>
</div>
{teamId && (
<>
<p className="mt-4 hidden rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:block">
Preparing the roadmap might take some time, feel free to save it
as a placeholder and anyone with the role <strong>admin</strong>{' '}
or <strong>manager</strong> can prepare it later.
</p>
<p className="mt-4 rounded-md border border-orange-200 bg-orange-50 p-2.5 text-sm text-orange-600 sm:hidden">
Create a placeholder now and prepare it later.
</p>
</>
)}
</form>
</Modal>
);
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import {
type AppError,
type FetchError,
httpGet,
httpPost,
} from '../../lib/http';
import { RoadmapHeader } from './RoadmapHeader';
import { RoadmapRenderer } from './RoadmapRenderer';
import { TopicDetail } from '../TopicDetail/TopicDetail';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { currentRoadmap } from '../../stores/roadmap';
import { UserProgressModal } from '../UserProgress/UserProgressModal';
import { RestrictedPage } from './RestrictedPage';
import { isLoggedIn } from '../../lib/jwt';
export const allowedLinkTypes = [
'video',
'article',
'opensource',
'course',
'website',
'podcast',
] as const;
export type AllowedLinkTypes = (typeof allowedLinkTypes)[number];
export interface RoadmapContentDocument {
_id?: string;
roadmapId: string;
nodeId: string;
title: string;
description: string;
links: {
id: string;
type: AllowedLinkTypes;
title: string;
url: string;
}[];
}
export function hideRoadmapLoader() {
const loaderEl = document.querySelector(
'[data-roadmap-loader]'
) as HTMLElement;
if (loaderEl) {
loaderEl.remove();
}
}
export function CustomRoadmap() {
const { id, secret } = getUrlParams() as { id: string; secret: string };
const [isLoading, setIsLoading] = useState(true);
const [roadmap, setRoadmap] = useState<RoadmapDocument | null>(null);
const [error, setError] = useState<AppError | FetchError | undefined>();
async function getRoadmap() {
setIsLoading(true);
const roadmapUrl = new URL(
`${import.meta.env.PUBLIC_API_URL}/v1-get-roadmap/${id}`
);
if (secret) {
roadmapUrl.searchParams.set('secret', secret);
}
const { response, error } = await httpGet<RoadmapDocument>(
roadmapUrl.toString()
);
if (error || !response) {
setError(error);
setIsLoading(false);
return;
}
document.title = `${response.title} - roadmap.sh`;
setRoadmap(response);
currentRoadmap.set(response);
setIsLoading(false);
}
async function trackVisit() {
if (!isLoggedIn()) return;
await httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
resourceId: id,
resourceType: 'roadmap',
});
}
useEffect(() => {
getRoadmap().finally(() => {
hideRoadmapLoader();
});
trackVisit().then();
}, []);
if (isLoading) {
return null;
}
if (error) {
return <RestrictedPage error={error} />;
}
return (
<>
<RoadmapHeader />
<RoadmapRenderer roadmap={roadmap!} />
<TopicDetail canSubmitContribution={false} />
<UserProgressModal
resourceId={roadmap?._id!}
resourceType="roadmap"
isCustomResource={true}
/>
</>
);
}

View File

@@ -0,0 +1,12 @@
import { CircleSlash } from 'lucide-react';
export function EmptyRoadmap() {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center">
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
<h3 className="mt-4">This roadmap is currently empty.</h3>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
import MoreIcon from '../../icons/more-vertical.svg';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
type PersonalRoadmapActionDropdownProps = {
onDelete?: () => void;
onCustomize?: () => void;
onUpdateSharing?: () => void;
};
export function PersonalRoadmapActionDropdown(props: PersonalRoadmapActionDropdownProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
return (
<div className="relative">
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="hidden items-center opacity-60 transition-opacity hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30 sm:flex"
>
<img alt="menu" src={MoreIcon.src} className="h-4 w-4" />
</button>
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none sm:hidden"
>
<MoreVertical size={14} />
Options
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-auto top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md sm:right-0"
>
<ul>
{onUpdateSharing && (
<li>
<button
onClick={() => {
setIsOpen(false);
onUpdateSharing();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Lock size={14} className="mr-2" />
Sharing
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button
onClick={() => {
setIsOpen(false);
onDelete();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Trash2 size={14} className="mr-2" />
Delete
</button>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,238 @@
import { httpDelete } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import {
ExternalLink,
Shapes,
type LucideIcon,
Globe,
LockIcon,
Users,
} from 'lucide-react';
import { useToast } from '../../hooks/use-toast';
import {
type AllowedRoadmapVisibility,
type RoadmapDocument,
} from './CreateRoadmap/CreateRoadmapModal';
import RoadmapIcon from '../../icons/roadmap.svg';
import { PersonalRoadmapActionDropdown } from './PersonalRoadmapActionDropdown';
import type { GetRoadmapListResponse } from './RoadmapListPage';
import { useState, type Dispatch, type SetStateAction } from 'react';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
type PersonalRoadmapListType = {
roadmaps: GetRoadmapListResponse['personalRoadmaps'];
onDelete: (roadmapId: string) => void;
setAllRoadmaps: Dispatch<SetStateAction<GetRoadmapListResponse>>;
};
export function PersonalRoadmapList(props: PersonalRoadmapListType) {
const { roadmaps: roadmapList, onDelete, setAllRoadmaps } = props;
const toast = useToast();
const [selectedRoadmap, setSelectedRoadmap] = useState<
GetRoadmapListResponse['personalRoadmaps'][number] | null
>(null);
async function deleteRoadmap(roadmapId: string) {
const { response, error } = await httpDelete<RoadmapDocument[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-roadmap/${roadmapId}`
);
if (error || !response) {
console.error(error);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
toast.success('Roadmap deleted');
onDelete(roadmapId);
}
async function onRemove(roadmapId: string) {
pageProgressMessage.set('Deleting roadmap');
deleteRoadmap(roadmapId).finally(() => {
pageProgressMessage.set('');
});
}
const shareSettingsModal = selectedRoadmap && (
<ShareOptionsModal
visibility={selectedRoadmap.visibility}
sharedFriendIds={selectedRoadmap.sharedFriendIds}
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds}
roadmapId={selectedRoadmap._id!}
onClose={() => setSelectedRoadmap(null)}
onShareSettingsUpdate={(settings) => {
setAllRoadmaps((prev) => {
return {
...prev,
personalRoadmaps: prev.personalRoadmaps.map((roadmap) => {
if (roadmap._id === selectedRoadmap._id) {
return {
...roadmap,
...settings,
};
}
return roadmap;
}),
};
});
}}
/>
);
if (roadmapList.length === 0) {
return (
<div className="flex flex-col items-center p-4 py-20">
<img
alt="roadmap"
src={RoadmapIcon.src}
className="mb-4 h-24 w-24 opacity-10"
/>
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
Create a roadmap to get started
</p>
</div>
);
}
return (
<div>
{shareSettingsModal}
<div className="mb-3 flex items-center justify-between">
<span className={'text-sm text-gray-400'}>
{roadmapList.length} custom roadmap(s)
</span>
</div>
<ul className="flex flex-col divide-y rounded-md border">
{roadmapList.map((roadmap) => {
return (
<CustomRoadmapItem
key={roadmap._id!}
roadmap={roadmap}
onRemove={onRemove}
setSelectedRoadmap={setSelectedRoadmap}
/>
);
})}
</ul>
</div>
);
}
type CustomRoadmapItemProps = {
roadmap: GetRoadmapListResponse['personalRoadmaps'][number];
onRemove: (roadmapId: string) => Promise<void>;
setSelectedRoadmap: (
roadmap: GetRoadmapListResponse['personalRoadmaps'][number] | null
) => void;
};
function CustomRoadmapItem(props: CustomRoadmapItemProps) {
const { roadmap, onRemove, setSelectedRoadmap } = props;
const editorLink = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmap._id}`;
return (
<li
className="grid grid-cols-1 p-2.5 sm:grid-cols-[auto_110px]"
key={roadmap._id!}
>
<div className="mb-3 grid grid-cols-1 sm:mb-0">
<p className="mb-1.5 truncate text-base font-medium leading-tight text-black">
{roadmap.title}
</p>
<span className="flex items-center text-xs leading-none text-gray-400">
<VisibilityBadge
visibility={roadmap.visibility!}
sharedFriendIds={roadmap.sharedFriendIds}
/>
<span className="mx-2 font-semibold">&middot;</span>
<Shapes size={16} className="mr-1 inline-block h-4 w-4" />
{roadmap.topics} topic
</span>
</div>
<div className="mr-1 flex items-center justify-start sm:justify-end">
<PersonalRoadmapActionDropdown
onUpdateSharing={() => {
setSelectedRoadmap(roadmap);
}}
onCustomize={() => {
window.open(editorLink, '_blank');
}}
onDelete={() => {
if (confirm('Are you sure you want to remove this roadmap?')) {
onRemove(roadmap._id!).finally(() => {});
}
}}
/>
<a
href={`/r?id=${roadmap._id}`}
className={
'ml-2 flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-xs hover:bg-gray-50 focus:outline-none'
}
target={'_blank'}
>
<ExternalLink className="inline-block h-4 w-4" />
Visit
</a>
</div>
</li>
);
}
type VisibilityLabelProps = {
visibility: AllowedRoadmapVisibility;
sharedFriendIds?: string[];
};
const visibilityDetails: Record<
AllowedRoadmapVisibility,
{
icon: LucideIcon;
label: string;
}
> = {
public: {
icon: Globe,
label: 'Public',
},
me: {
icon: LockIcon,
label: 'Only me',
},
team: {
icon: Users,
label: 'Team Member(s)',
},
friends: {
icon: Users,
label: 'Friend(s)',
},
} as const;
function VisibilityBadge(props: VisibilityLabelProps) {
const { visibility, sharedFriendIds = [] } = props;
const { label, icon: Icon } = visibilityDetails[visibility];
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap text-xs font-normal`}
>
<Icon className="inline-block h-3 w-3" />
<div className="flex items-center">
{visibility === 'friends' && sharedFriendIds?.length > 0 && (
<span className="mr-1">{sharedFriendIds.length}</span>
)}
{label}
</div>
</span>
);
}

View File

@@ -0,0 +1,111 @@
import { HelpCircle } from 'lucide-react';
import { cn } from '../../lib/classname';
import type { ResourceType } from '../../lib/resource-progress';
import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
type ResourceProgressStatsProps = {
resourceId: string;
resourceType: ResourceType;
isSecondaryBanner?: boolean;
};
export function ResourceProgressStats(props: ResourceProgressStatsProps) {
const { isSecondaryBanner = false } = props;
const [isSharing, setIsSharing] = useState(false);
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
const $currentRoadmap = useStore(currentRoadmap);
return (
<>
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
<ShareOptionsModal
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={$currentRoadmap?.sharedTeamMemberIds || []}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<div
data-progress-nums-container=""
className={cn(
'striped-loader relative hidden items-center justify-between bg-white px-2 py-1.5 sm:flex',
{
'rounded-bl-md rounded-br-md': isSecondaryBanner,
'rounded-md': !isSecondaryBanner,
}
)}
>
<p
className="flex text-sm opacity-0 transition-opacity duration-300"
data-progress-nums=""
>
<span className="mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900">
<span data-progress-percentage="">0</span>% Done
</span>
<span className="itesm-center hidden md:flex">
<span>
<span data-progress-done="">0</span> completed
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-learning="">0</span> in progress
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-skipped="">0</span> skipped
</span>
<span className="mx-1.5 text-gray-400">&middot;</span>
<span>
<span data-progress-total="">0</span> Total
</span>
</span>
<span className="md:hidden">
<span data-progress-done="">0</span> of{' '}
<span data-progress-total="">0</span> Done
</span>
</p>
<div
className="flex items-center gap-3 opacity-0 transition-opacity duration-300"
data-progress-nums=""
>
<button
data-popup="progress-help"
className="flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black"
data-progress-nums=""
>
<HelpCircle className="h-3.5 w-3.5 stroke-[2.5px]" />
Track Progress
</button>
</div>
</div>
<div
data-progress-nums-container=""
className="striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
>
<span
data-progress-nums=""
className="text-gray-500 opacity-0 transition-opacity duration-300"
>
<span data-progress-done="">0</span> of{' '}
<span data-progress-total="">0</span> Done
</span>
</div>
</>
);
}

View File

@@ -0,0 +1,52 @@
import { ShieldBan } from 'lucide-react';
import type { FetchError } from '../../lib/http';
type RestrictedPageProps = {
error: FetchError;
};
export function RestrictedPage(props: RestrictedPageProps) {
const { error } = props;
if (error.status === 404) {
return (
<ErrorMessage
icon={<ShieldBan className="h-16 w-16" />}
title="Roadmap not found"
message="The roadmap you are looking for does not exist or has been deleted."
/>
);
}
return (
<ErrorMessage
icon={<ShieldBan className="h-16 w-16" />}
title="Restricted Access"
message={error?.message}
/>
);
}
type ErrorMessageProps = {
title: string;
message: string;
icon: React.ReactNode;
};
function ErrorMessage(props: ErrorMessageProps) {
const { title, message, icon } = props;
return (
<div className="flex grow flex-col items-center justify-center">
{icon}
<h2 className="mt-4 text-2xl font-semibold">{title}</h2>
<p>{message || 'This roadmap is not available for public access.'}</p>
<a
href="/"
className="mt-4 font-medium underline underline-offset-2 hover:no-underline"
>
&larr; Go back to home
</a>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { Lock, MoreVertical, Shapes, Trash2 } from 'lucide-react';
type RoadmapActionButtonProps = {
onDelete?: () => void;
onCustomize?: () => void;
onUpdateSharing?: () => void;
};
export function RoadmapActionButton(props: RoadmapActionButtonProps) {
const { onDelete, onUpdateSharing, onCustomize } = props;
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
useOutsideClick(menuRef, () => {
setIsOpen(false);
});
return (
<div className="relative">
<button
disabled={false}
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center rounded-md bg-gray-500 py-1.5 pl-2 pr-2 text-xs font-medium text-white hover:bg-gray-600 sm:pl-1.5 sm:pr-3 sm:text-sm"
>
<MoreVertical className="mr-0 h-4 w-4 stroke-[2.5] sm:mr-1.5" />
<span className="hidden sm:inline">Actions</span>
</button>
{isOpen && (
<div
ref={menuRef}
className="align-right absolute right-0 top-full z-50 mt-1 w-[140px] rounded-md bg-slate-800 px-2 py-2 text-white shadow-md"
>
<ul>
{onUpdateSharing && (
<li>
<button
onClick={() => {
setIsOpen(false);
onUpdateSharing();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Lock size={14} className="mr-2" />
Sharing
</button>
</li>
)}
{onCustomize && (
<li>
<button
onClick={() => {
setIsOpen(false);
onCustomize();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Shapes size={14} className="mr-2" />
Customize
</button>
</li>
)}
{onDelete && (
<li>
<button
onClick={() => {
setIsOpen(false);
onDelete();
}}
className="flex w-full cursor-pointer items-center rounded p-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
<Trash2 size={14} className="mr-2" />
Delete
</button>
</li>
)}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { RoadmapHint } from './RoadmapHint';
import { useStore } from '@nanostores/react';
import { canManageCurrentRoadmap, currentRoadmap } from '../../stores/roadmap';
import { ShareOptionsModal } from '../ShareOptions/ShareOptionsModal';
import { useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { httpDelete, httpPut } from '../../lib/http';
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { RoadmapActionButton } from './RoadmapActionButton';
type RoadmapHeaderProps = {};
export function RoadmapHeader(props: RoadmapHeaderProps) {
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
const $currentRoadmap = useStore(currentRoadmap);
const { title, description, _id: roadmapId } = useStore(currentRoadmap) || {};
const [isSharing, setIsSharing] = useState(false);
const toast = useToast();
async function deleteResource() {
pageProgressMessage.set('Deleting roadmap');
const teamId = $currentRoadmap?.teamId;
const baseApiUrl = import.meta.env.PUBLIC_API_URL;
let error, response;
if (teamId) {
({ error, response } = await httpPut<TeamResourceConfig>(
`${baseApiUrl}/v1-delete-team-resource-config/${teamId}`,
{
resourceId: roadmapId,
resourceType: 'roadmap',
}
));
} else {
({ error, response } = await httpDelete<TeamResourceConfig>(
`${baseApiUrl}/v1-delete-roadmap/${roadmapId}`
));
}
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success('Roadmap removed');
if (!teamId) {
window.location.href = '/account/roadmaps';
} else {
window.location.href = `/team/roadmaps?t=${teamId}`;
}
}
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="mb-3 mt-0 sm:mb-4">
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
{description}
</p>
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className="flex gap-1 sm:gap-2">
<a
href="/roadmaps"
className="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
aria-label="Back to All Roadmaps"
>
&larr;<span className="hidden sm:inline">&nbsp;All Roadmaps</span>
</a>
<button
data-guest-required
data-popup="login-popup"
className="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
aria-label="Subscribe for Updates"
>
<span className="ml-2">Subscribe</span>
</button>
</div>
{$canManageCurrentRoadmap && (
<div className="flex items-center gap-2">
{isSharing && $currentRoadmap && (
<ShareOptionsModal
visibility={$currentRoadmap?.visibility}
teamId={$currentRoadmap?.teamId}
roadmapId={$currentRoadmap?._id!}
sharedFriendIds={$currentRoadmap?.sharedFriendIds || []}
sharedTeamMemberIds={
$currentRoadmap?.sharedTeamMemberIds || []
}
onClose={() => setIsSharing(false)}
onShareSettingsUpdate={(settings) => {
currentRoadmap.set({
...$currentRoadmap,
...settings,
});
}}
/>
)}
<RoadmapActionButton
onDelete={() => {
const confirmation = window.confirm(
'Are you sure you want to delete this roadmap?'
);
if (!confirmation) {
return;
}
deleteResource().finally(() => null);
}}
onCustomize={() => {
const editorLink = `${
import.meta.env.PUBLIC_EDITOR_APP_URL
}/${$currentRoadmap?._id}`;
window.open(editorLink, '_blank');
}}
onUpdateSharing={() => {
setIsSharing(true);
}}
/>
</div>
)}
</div>
<RoadmapHint
roadmapTitle={title!}
hasTNSBanner={false}
roadmapId={roadmapId!}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { cn } from '../../lib/classname';
import { ResourceProgressStats } from './ResourceProgressStats';
type RoadmapHintProps = {
roadmapId: string;
roadmapTitle: string;
hasTNSBanner?: boolean;
tnsBannerLink?: string;
};
export function RoadmapHint(props: RoadmapHintProps) {
const {
roadmapTitle,
roadmapId,
hasTNSBanner = false,
tnsBannerLink = '',
} = props;
return (
<div
className={cn('mb-0 mt-4 rounded-md border-0 sm:mt-7 sm:border', {
'sm:-mb-[82px]': hasTNSBanner,
'sm:-mb-[65px]': !hasTNSBanner,
})}
>
{hasTNSBanner && (
<div className="hidden border-b bg-gray-100 px-2 py-1.5 sm:block">
<p className="text-sm">
Get the latest {roadmapTitle} news from our sister site{' '}
<a
href={tnsBannerLink}
target="_blank"
className="font-semibold underline"
>
TheNewStack.io
</a>
</p>
</div>
)}
<ResourceProgressStats
isSecondaryBanner={hasTNSBanner}
resourceId={roadmapId}
resourceType="roadmap"
/>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { httpGet } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import {
CreateRoadmapModal,
type RoadmapDocument,
} from './CreateRoadmap/CreateRoadmapModal';
import { PersonalRoadmapList } from './PersonalRoadmapList';
import { useToast } from '../../hooks/use-toast';
import { SharedRoadmapList } from './SharedRoadmapList';
import type { FriendshipStatus } from '../Befriend';
export type FriendUserType = {
id: string;
name: string;
avatar: string;
status: FriendshipStatus;
};
export type GetRoadmapListResponse = {
personalRoadmaps: (RoadmapDocument & {
topics: number;
})[];
sharedRoadmaps: (RoadmapDocument & {
topics: number;
creator: FriendUserType;
})[];
};
type TabType = {
label: string;
value: 'personal' | 'shared';
};
const tabTypes: TabType[] = [
{ label: 'Personal', value: 'personal' },
{ label: 'Shared by Friends', value: 'shared' },
];
export function RoadmapListPage() {
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
const [activeTab, setActiveTab] = useState<TabType['value']>('personal');
const [allRoadmaps, setAllRoadmaps] = useState<GetRoadmapListResponse>({
personalRoadmaps: [],
sharedRoadmaps: [],
});
async function loadRoadmapList() {
setIsLoading(true);
const { response, error } = await httpGet<GetRoadmapListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-roadmap-list`
);
if (error || !response) {
console.error(error);
toast.error(error?.message || 'Something went wrong, please try again');
return;
}
setAllRoadmaps(
response! || {
personalRoadmaps: [],
sharedRoadmaps: [],
}
);
}
useEffect(() => {
loadRoadmapList().finally(() => {
setIsLoading(false);
pageProgressMessage.set('');
});
}, []);
if (isLoading) {
return null;
}
return (
<div>
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-2">
{tabTypes.map((tab) => {
return (
<button
key={tab.value}
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
} w-full sm:w-auto`}
onClick={() => setActiveTab(tab.value)}
>
{tab.label}
</button>
);
})}
</div>
<button
className={`relative flex w-full items-center justify-center rounded-md border p-1 px-3 text-sm sm:w-auto`}
onClick={() => setIsCreatingRoadmap(true)}
>
+ Create Roadmap
</button>
</div>
<div className="mt-4">
{activeTab === 'personal' && (
<PersonalRoadmapList
roadmaps={allRoadmaps?.personalRoadmaps}
setAllRoadmaps={setAllRoadmaps}
onDelete={(roadmapId) => {
setAllRoadmaps({
...allRoadmaps,
personalRoadmaps: allRoadmaps.personalRoadmaps.filter(
(r) => r._id !== roadmapId
),
});
}}
/>
)}
{activeTab === 'shared' && (
<SharedRoadmapList roadmaps={allRoadmaps?.sharedRoadmaps} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
svg text tspan {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeSpeed;
}
svg > g[data-type='topic'],
svg > g[data-type='subtopic'],
svg > g > g[data-type='link-item'],
svg > g[data-type='button'] {
cursor: pointer;
}
svg > g[data-type='topic']:hover > rect {
fill: #d6d700;
}
svg > g[data-type='subtopic']:hover > rect {
fill: #f3c950;
}
svg > g[data-type='button']:hover {
opacity: 0.8;
}
svg .done rect {
fill: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
}
svg > g[data-type='topic'].learning > rect + text,
svg > g[data-type='topic'].done > rect + text {
fill: black;
}
svg > g[data-type='subtipic'].done > rect + text,
svg > g[data-type='subtipic'].learning > rect + text {
fill: #cbcbcb;
}
svg .learning rect {
fill: #dad1fd !important;
}
svg .learning text {
text-decoration: underline;
}
svg .skipped rect {
fill: #496b69 !important;
}

View File

@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Renderer } from '../../../renderer';
import './RoadmapRenderer.css';
import {
renderResourceProgress,
updateResourceProgress,
type ResourceProgressType,
renderTopicProgress,
refreshProgressCounters,
} from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
import { EmptyRoadmap } from './EmptyRoadmap';
import { isLoggedIn } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
type RoadmapRendererProps = {
roadmap: RoadmapDocument;
};
type RoadmapNodeDetails = {
nodeId: string;
nodeType: string;
targetGroup: SVGElement;
};
export function getNodeDetails(
svgElement: SVGElement
): RoadmapNodeDetails | null {
const targetGroup = (svgElement?.closest('g') as SVGElement) || {};
const nodeId = targetGroup?.dataset?.nodeId;
const nodeType = targetGroup?.dataset?.type;
if (!nodeId || !nodeType) return null;
return { nodeId, nodeType, targetGroup };
}
export const allowedClickableNodeTypes = [
'topic',
'subtopic',
'button',
'link-item',
];
export function RoadmapRenderer(props: RoadmapRendererProps) {
const { roadmap } = props;
const roadmapRef = useRef<HTMLDivElement>(null);
const roadmapId = roadmap._id!;
const toast = useToast();
const [hideRenderer, setHideRenderer] = useState(false);
async function updateTopicStatus(
topicId: string,
newStatus: ResourceProgressType
) {
pageProgressMessage.set('Updating progress');
updateResourceProgress(
{
resourceId: roadmapId,
resourceType: 'roadmap',
topicId,
},
newStatus
)
.then(() => {
renderTopicProgress(topicId, newStatus);
})
.catch((err) => {
toast.error('Something went wrong, please try again.');
console.error(err);
})
.finally(() => {
pageProgressMessage.set('');
refreshProgressCounters();
});
return;
}
const handleSvgClick = useCallback((e: MouseEvent) => {
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
const link = targetGroup?.dataset?.link || '';
const isExternalLink = link.startsWith('http');
if (isExternalLink) {
window.open(link, '_blank');
} else {
window.location.href = link;
}
return;
}
const isCurrentStatusLearning = targetGroup?.classList.contains('learning');
const isCurrentStatusSkipped = targetGroup?.classList.contains('skipped');
if (e.shiftKey) {
e.preventDefault();
updateTopicStatus(
nodeId,
isCurrentStatusLearning ? 'pending' : 'learning'
);
return;
} else if (e.altKey) {
e.preventDefault();
updateTopicStatus(nodeId, isCurrentStatusSkipped ? 'pending' : 'skipped');
return;
}
window.dispatchEvent(
new CustomEvent('roadmap.node.click', {
detail: {
topicId: nodeId,
resourceId: roadmap?._id,
resourceType: 'roadmap',
isCustomResource: true,
},
})
);
}, []);
const handleSvgRightClick = useCallback((e: MouseEvent) => {
e.preventDefault();
const target = e.target as SVGElement;
const { nodeId, nodeType, targetGroup } = getNodeDetails(target) || {};
if (!nodeId || !nodeType || !allowedClickableNodeTypes.includes(nodeType))
return;
if (nodeType === 'button' || nodeType === 'link-item') {
return;
}
const isCurrentStatusDone = targetGroup?.classList.contains('done');
updateTopicStatus(nodeId, isCurrentStatusDone ? 'pending' : 'done');
}, []);
useEffect(() => {
if (!roadmapRef?.current) return;
roadmapRef?.current?.addEventListener('click', handleSvgClick);
roadmapRef?.current?.addEventListener('contextmenu', handleSvgRightClick);
return () => {
roadmapRef?.current?.removeEventListener('click', handleSvgClick);
roadmapRef?.current?.removeEventListener(
'contextmenu',
handleSvgRightClick
);
};
}, []);
return (
<div className="flex grow bg-gray-50 pb-8 pt-4 sm:pt-12">
<div className="container !max-w-[1000px]">
<Renderer
ref={roadmapRef}
roadmap={{ nodes: roadmap?.nodes!, edges: roadmap?.edges! }}
onRendered={() => {
renderResourceProgress('roadmap', roadmapId).then(() => {
if (roadmap?.nodes?.length === 0) {
setHideRenderer(true);
roadmapRef?.current?.classList.add('hidden');
}
});
}}
/>
{hideRenderer && <EmptyRoadmap />}
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import { useStore } from '@nanostores/react';
import { Check, Copy, Loader2 } from 'lucide-react';
import { Modal } from '../Modal';
import type { AllowedRoadmapVisibility } from './CreateRoadmap/CreateRoadmapModal';
import { cn } from '../../lib/classname';
import { httpPatch } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { useCopyText } from '../../hooks/use-copy-text';
import { currentRoadmap, isCurrentRoadmapPersonal } from '../../stores/roadmap';
type ShareRoadmapModalProps = {
onClose: () => void;
};
export const allowedVisibilityLabels: {
id: AllowedRoadmapVisibility;
label: string;
}[] = [
{
id: 'me',
label: 'Only visible to me',
},
{
id: 'public',
label: 'Anyone with the link',
},
{
id: 'team',
label: 'Visible to team members',
},
{
id: 'friends',
label: 'Only friends can view',
},
];
export function ShareRoadmapModal(props: ShareRoadmapModalProps) {
const { onClose } = props;
const toast = useToast();
const $currentRoadmap = useStore(currentRoadmap);
const $isCurrentRoadmapPersonal = useStore(isCurrentRoadmapPersonal);
const roadmapId = $currentRoadmap?._id!;
const { copyText, isCopied } = useCopyText();
const [visibility, setVisibility] = useState($currentRoadmap?.visibility);
const [isLoading, setIsLoading] = useState(false);
async function updateVisibility(newVisibility: AllowedRoadmapVisibility) {
setIsLoading(true);
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-roadmap-visibility/${
$currentRoadmap?._id
}`,
{
visibility: newVisibility,
}
);
if (error) {
console.error(error);
toast.error(error?.message || 'Something went wrong, please try again');
setIsLoading(false);
return;
}
setIsLoading(false);
toast.success('Visibility updated');
setVisibility(newVisibility);
currentRoadmap.set({
...$currentRoadmap!,
visibility: newVisibility,
});
}
function handleCopy() {
const isDev = import.meta.env.DEV;
const url = new URL(
isDev ? 'http://localhost:3000/r' : 'https://roadmap.sh/r'
);
url.searchParams.set('id', roadmapId);
copyText(url.toString());
}
return (
<Modal onClose={onClose}>
<div className="p-4 pb-0">
<h1 className="text-lg font-medium leading-5 text-gray-900">
Updating {$currentRoadmap?.title}
</h1>
</div>
<ul className="mt-4 border-t">
{allowedVisibilityLabels.map((v) => {
if (v.id === 'team' && $isCurrentRoadmapPersonal) {
return null;
} else if (v.id === 'friends' && !$isCurrentRoadmapPersonal) {
return null;
}
return (
<li key={v.id}>
<button
disabled={v.id === visibility || isLoading}
key={v.id}
className={cn(
'relative flex w-full items-center border-b p-2.5 px-4 text-sm text-gray-700 hover:bg-gray-200 hover:text-gray-900 disabled:cursor-not-allowed',
v.id === visibility &&
'bg-gray-900 text-white hover:bg-gray-900 hover:text-white'
)}
onClick={() => updateVisibility(v.id)}
>
{v.label}
{v.id === visibility && (
<span className="absolute bottom-0 right-0 top-0 flex w-8 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-green-500" />
</span>
)}
</button>
</li>
);
})}
</ul>
<div className="flex items-center justify-between p-4">
<button
disabled={isLoading}
className="flex h-9 items-center rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-black outline-none hover:border-gray-300 hover:bg-gray-50 focus:border-gray-300 focus:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-70"
onClick={onClose}
>
{isLoading ? (
<>
<Loader2 size={14} className="mr-2 animate-spin stroke-[2.5]" />
Saving
</>
) : (
'Cancel'
)}
</button>
<button
className="flex h-9 items-center justify-center rounded-md border border-transparent bg-gray-900 px-4 py-2 text-sm font-medium text-white outline-none hover:bg-gray-800 focus:bg-gray-800"
onClick={handleCopy}
>
{isCopied ? (
<>
<Check size={14} className="mr-2 stroke-[2.5]" />
Copied
</>
) : (
<>
<Copy size={14} className="mr-2 stroke-[2.5]" />
Copy Link
</>
)}
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,118 @@
import { ExternalLinkIcon, Map, Plus } from 'lucide-react';
import RoadmapIcon from '../../icons/roadmap.svg';
import type { GetRoadmapListResponse } from './RoadmapListPage';
type GroupByCreator = {
creator: GetRoadmapListResponse['sharedRoadmaps'][number]['creator'];
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
};
type SharedRoadmapListProps = {
roadmaps: GetRoadmapListResponse['sharedRoadmaps'];
};
export function SharedRoadmapList(props: SharedRoadmapListProps) {
const { roadmaps: sharedRoadmaps } = props;
const allUniqueCreatorIds = new Set(
sharedRoadmaps.map((roadmap) => roadmap.creator.id)
);
const groupByCreator: GroupByCreator[] = [];
for (const creatorId of allUniqueCreatorIds) {
const creator = sharedRoadmaps.find(
(roadmap) => roadmap.creator.id === creatorId
)?.creator;
if (!creator) {
continue;
}
groupByCreator.push({
creator,
roadmaps: sharedRoadmaps.filter(
(roadmap) => roadmap.creator.id === creatorId
),
});
}
if (sharedRoadmaps.length === 0) {
return (
<div className="flex flex-col items-center p-4 py-20">
<Map className="mb-4 h-24 w-24 opacity-10" />
<h3 className="mb-1 text-2xl font-bold text-gray-900">No roadmaps</h3>
<p className="text-base text-gray-500">
Roadmaps from your friends will appear here
</p>
</div>
);
}
return (
<div>
<div className="mb-3 flex items-center justify-between">
<span className={'text-sm text-gray-400'}>
{sharedRoadmaps.length} shared roadmap(s)
</span>
</div>
<div>
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{groupByCreator.map((group) => {
const creator = group.creator;
return (
<li
key={creator.id}
className="flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
>
<div className="relative flex w-full items-center gap-3 p-3">
<img
src={
creator.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${
creator.avatar
}`
: '/images/default-avatar.png'
}
alt={creator.name || ''}
className="h-8 w-8 rounded-full"
/>
<div>
<h3 className="truncate font-medium">{creator.name}</h3>
<p className="truncate text-sm text-gray-500">
{group?.roadmaps?.length || 0} shared roadmap(s)
</p>
</div>
</div>
<ul className="w-full">
{group?.roadmaps?.map((roadmap) => {
return (
<li
key={roadmap._id}
className="relative flex w-full border-t"
>
<a
href={`/r?id=${roadmap._id}`}
className="group inline-grid w-full grid-cols-[auto,16px] items-center justify-between gap-2 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 hover:text-black"
target={'_blank'}
>
<span className="w-full truncate">
{roadmap.title}
</span>
<ExternalLinkIcon
size={16}
className="opacity-20 transition-opacity group-hover:opacity-100"
/>
</a>
</li>
);
})}
</ul>
</li>
);
})}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
export function SkeletonRoadmapHeader() {
return (
<div className="border-b">
<div className="container relative py-5 sm:py-12">
<div className="mb-3 mt-0 sm:mb-4">
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" />
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" />
</div>
<div className="flex justify-between gap-2 sm:gap-0">
<div className="h-7 w-[35.04px] sm:w-32 animate-pulse rounded-md bg-gray-300 sm:h-8" />
<div className="h-7 w-[32px] sm:w-[89.73px] animate-pulse rounded-md bg-gray-300 sm:h-8" />
</div>
<div className="mb-0 mt-4 rounded-md border-0 sm:-mb-[65px] sm:mt-7 sm:border">
<div
data-progress-nums-container
className="striped-loader relative hidden h-8 items-center justify-between rounded-md bg-white sm:flex"
/>
<div
data-progress-nums-container
className="striped-loader relative -mb-2 flex h-[34px] items-center justify-between rounded-md border bg-white px-2 py-1.5 text-sm text-gray-700 sm:hidden"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import {useEffect, useState} from 'preact/hooks';
import { type FormEvent, useEffect, useState } from 'react';
import { httpDelete } from '../../lib/http';
import { logout } from '../Navigation/navigation';
@@ -10,9 +10,9 @@ export function DeleteAccountForm() {
useEffect(() => {
setError('');
setConfirmationText('');
}, [])
}, []);
const handleSubmit = async (e: Event) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -53,7 +53,7 @@ export function DeleteAccountForm() {
type="text"
name="delete-account"
id="delete-account"
className="mt-2 block w-full rounded-md border border-gray-300 py-2 px-3 outline-none placeholder:text-gray-400 focus:border-gray-400"
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
placeholder={'Type "delete" to confirm'}
required
autoFocus

View File

@@ -12,6 +12,6 @@ import { DeleteAccountForm } from './DeleteAccountForm';
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
<DeleteAccountForm client:only />
<DeleteAccountForm client:only="react" />
</div>
</Popup>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { httpDelete } from '../lib/http';
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
import { useTeamId } from '../hooks/use-team-id';
@@ -34,7 +34,7 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
inputEl.current?.focus();
}, []);
const handleSubmit = async (e: Event) => {
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -69,18 +69,18 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
return (
<>
<div class="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div className="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
class="popup-body relative rounded-lg bg-white p-4 shadow"
className="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h2 class="text-2xl font-semibold text-black">Delete Team</h2>
<h2 className="text-2xl font-semibold text-black">Delete Team</h2>
<p className="text-gray-500">
This will permanently delete your team and all associated data.
</p>
<p class="-mb-2 mt-3 text-base font-medium text-black">
<p className="-mb-2 mt-3 text-base font-medium text-black">
Please type "delete" to confirm.
</p>
<form onSubmit={handleSubmit}>

View File

@@ -22,7 +22,7 @@ if (faqs.length === 0) {
<div class='border-t bg-gray-100 mt-8'>
<div class='container'>
<div class='flex justify-between relative -top-5'>
<h1 class='text-sm sm:text-base font-medium py-1 px-3 border bg-white rounded-md'>Frequently Asked Questions</h1>
<h2 class='text-sm sm:text-base font-medium py-1 px-3 border bg-white rounded-md'>Frequently Asked Questions</h2>
</div>
<div class='flex flex-col gap-1 pb-14'>

View File

@@ -16,8 +16,8 @@ export function FavoriteIcon(props: FavoriteIconProps) {
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5ZM5.93682 1.25C6.42732 1.25 6.83382 1.632 6.86382 2.122L7.24932 8.506C7.25216 8.55018 7.24229 8.59425 7.22089 8.63301C7.19949 8.67176 7.16745 8.70359 7.12854 8.72472C7.08964 8.74585 7.0455 8.75542 7.00134 8.75228C6.95718 8.74914 6.91484 8.73343 6.87932 8.707L4.27582 6.783C4.19591 6.72397 4.09917 6.69211 3.99982 6.69211C3.90047 6.69211 3.80373 6.72397 3.72382 6.783L1.11982 8.707C1.0843 8.73343 1.04196 8.74914 0.9978 8.75228C0.953639 8.75542 0.909502 8.74585 0.8706 8.72472C0.831697 8.70359 0.799653 8.67176 0.778252 8.63301C0.756851 8.59425 0.746986 8.55018 0.749822 8.506L1.13632 2.122C1.16632 1.632 1.57232 1.25 2.06282 1.25H5.93682Z"
fill="currentColor"
/>
@@ -35,8 +35,8 @@ export function FavoriteIcon(props: FavoriteIconProps) {
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5Z"
fill="currentColor"
/>

View File

@@ -6,11 +6,18 @@ export interface FeaturedItemType {
isNew?: boolean;
url: string;
text: string;
allowBookmark?: boolean;
}
export interface Props extends FeaturedItemType {}
const { isUpcoming = false, isNew = false, text, url } = Astro.props;
const {
isUpcoming = false,
isNew = false,
text,
url,
allowBookmark = true,
} = Astro.props;
---
<a
@@ -26,11 +33,17 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
{text}
</span>
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
client:load
/>
{
allowBookmark && (
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={
url.includes('best-practices') ? 'best-practice' : 'roadmap'
}
client:only='react'
/>
)
}
{
isNew && (

View File

@@ -1,18 +1,26 @@
---
import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import FeaturedItem, { type FeaturedItemType } from './FeaturedItem.astro';
export interface Props {
featuredItems: FeaturedItemType[];
heading: string;
showCreateRoadmap?: boolean;
allowBookmark?: boolean;
}
const { featuredItems, heading } = Astro.props;
const {
featuredItems,
heading,
showCreateRoadmap,
allowBookmark = true,
} = Astro.props;
---
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
<div class='container'>
<h2
class='text-md font-regular absolute 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'
class='text-md font-regular absolute -top-[17px] flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2'
>
{heading}
</h2>
@@ -22,6 +30,7 @@ const { featuredItems, heading } = Astro.props;
featuredItems.map((featuredItem) => (
<li>
<FeaturedItem
allowBookmark={allowBookmark}
text={featuredItem.text}
url={featuredItem.url}
isNew={featuredItem.isNew}
@@ -30,6 +39,19 @@ const { featuredItems, heading } = Astro.props;
</li>
))
}
{
showCreateRoadmap && (
<li>
<CreateRoadmapButton
client:load
className='min-h-[54px]'
type={
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
}
/>
</li>
)
}
</ul>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import type { MouseEvent } from "react";
import { httpPatch } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress';
import { isLoggedIn } from '../../lib/jwt';
@@ -20,15 +21,16 @@ export function MarkFavorite({
favorite,
className,
}: MarkFavoriteType) {
const isAuthenticated = isLoggedIn();
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isFavorite, setIsFavorite] = useState(
favorite ?? localStorage.getItem(localStorageKey) === '1'
isAuthenticated ? (favorite ?? localStorage.getItem(localStorageKey) === '1') : false
);
async function toggleFavoriteHandler(e: Event) {
async function toggleFavoriteHandler(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
@@ -94,6 +96,7 @@ export function MarkFavorite({
return (
<button
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
onClick={toggleFavoriteHandler}
tabIndex={-1}
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${

View File

@@ -0,0 +1,146 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { useTeamId } from '../../hooks/use-team-id';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { httpPost } from '../../lib/http';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type SubmitFeedbackPopupProps = {
onClose: () => void;
};
export function SubmitFeedbackPopup(props: SubmitFeedbackPopupProps) {
const { onClose } = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
const inputEl = useRef<HTMLTextAreaElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [feedbackText, setFeedbackText] = useState('');
const [isSuccess, setIsSuccess] = useState(false);
const { teamId } = useTeamId();
useOutsideClick(popupBodyEl, () => {
onClose();
});
useKeydown('Escape', () => {
onClose();
});
useEffect(() => {
inputEl.current?.focus();
}, []);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-submit-team-feedback/${teamId}`,
{
feedback: feedbackText,
}
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
setIsSuccess(true);
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setFeedbackText('');
onClose();
};
return (
<div className="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white p-4 shadow"
>
{!isSuccess && (
<>
<h2 className="mb-1 text-xl font-semibold text-black">
Enter your feedback
</h2>
<p className={'text-sm text-gray-500'}>
Help us improve your experience.
</p>
<form onSubmit={handleSubmit}>
<div className="my-4">
<textarea
ref={inputEl}
name="submit-feedback"
id="submit-feedback"
className="mt-2 block min-h-[150px] w-full resize-none rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400"
placeholder="Enter your feedback"
required
autoFocus
value={feedbackText}
onInput={(e) =>
setFeedbackText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-md bg-red-100 p-2 text-red-700">
{error}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isLoading}
onClick={handleClosePopup}
className="flex-grow cursor-pointer rounded-md bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
disabled={isLoading}
type="submit"
className="flex-grow cursor-pointer rounded-md bg-black py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Send'}
</button>
</div>
</form>
</>
)}
{isSuccess && (
<div className="flex w-full flex-col items-center">
<CheckIcon additionalClasses="w-14 h-14 text-green-500 mt-4" />
<h1 className="mt-4 text-xl font-semibold text-black sm:text-2xl">
Feedback Submitted
</h1>
<p className="text-center text-sm text-gray-500 sm:text-base">
Thank you for submitting your feedback.
</p>
<button
type="button"
onClick={handleClosePopup}
className="mt-4 w-full flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
>
Close
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -31,8 +31,7 @@ import Icon from './AstroIcon.astro';
<a
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'>YouTube</a
>
target='_blank'>YouTube</a>
</p>
<div class='flex flex-col justify-between gap-12 sm:flex-row'>
@@ -68,6 +67,7 @@ import Icon from './AstroIcon.astro';
<a href='/privacy' class='hover:text-white'>Privacy</a>
<span class='mx-1.5'>&middot;</span>
<a
aria-label="Subscribe to YouTube channel"
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'
class='hover:text-white'
@@ -75,6 +75,7 @@ import Icon from './AstroIcon.astro';
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
aria-label="Follow on Twitter"
href='https://twitter.com/roadmapsh'
target='_blank'
class='ml-2 hover:text-white'

View File

@@ -28,6 +28,9 @@ svg .clickable-group:hover > [fill='rgb(255,255,0)'] {
svg .clickable-group:hover > [fill='rgb(255,229,153)'] {
fill: #f3c950;
}
svg .clickable-group:hover > [stroke='rgb(255,229,153)'] {
stroke: #f3c950;
}
svg .clickable-group:hover > [fill='rgb(153,153,153)'] {
fill: #646464;
@@ -49,6 +52,10 @@ svg .done rect {
fill: #cbcbcb !important;
}
svg .done rect[stroke="rgb(255,229,153)"] {
stroke: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
@@ -73,7 +80,7 @@ svg .learning text {
svg .clickable-group.done[data-group-id^='check:'] rect {
fill: gray !important;
stroke: gray;
stroke: gray !important;
}
.clickable-group rect {
@@ -129,7 +136,7 @@ svg .removed path {
#customized-roadmap #resource-svg-wrap g:not([class]),
#customized-roadmap #resource-svg-wrap circle,
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
#customized-roadmap #resource-svg-wrap g[data-group-id$="-note"]{
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
display: none;
}

View File

@@ -5,10 +5,9 @@ import {
refreshProgressCounters,
renderResourceProgress,
renderTopicProgress,
ResourceProgressType,
ResourceType,
updateResourceProgress,
} from '../../lib/resource-progress';
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { showLoginPopup } from '../../lib/popup';

View File

@@ -11,19 +11,19 @@ export function EmptyFriends(props: EmptyFriendsProps) {
const { isCopied, copyText } = useCopyText();
return (
<div class="rounded-md">
<div class="mx-auto flex flex-col items-center p-7 text-center">
<div className="rounded-md">
<div className="mx-auto flex flex-col items-center p-7 text-center">
<img
alt="no friends"
src={UserPlusIcon}
class="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]"
src={UserPlusIcon.src}
className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]"
/>
<h2 class="text-lg font-bold sm:text-xl">Invite your Friends</h2>
<h2 className="text-lg font-bold sm:text-xl">Invite your Friends</h2>
<p className="mb-4 mt-1 max-w-[400px] text-sm leading-relaxed text-gray-500">
Share the unique link below with your friends to track their skills and progress.
</p>
<div class="flex w-full max-w-[352px] items-center justify-center gap-2 rounded-lg border-2 p-1 text-sm">
<div className="flex w-full max-w-[352px] items-center justify-center gap-2 rounded-lg border-2 p-1 text-sm">
<input
onClick={(e) => {
e.currentTarget.select();
@@ -31,11 +31,11 @@ export function EmptyFriends(props: EmptyFriendsProps) {
}}
type="text"
value={befriendUrl}
class="w-full border-none bg-transparent px-1.5 outline-none"
readonly
className="w-full border-none bg-transparent px-1.5 outline-none"
readOnly
/>
<button
class={`flex items-center justify-center gap-1 rounded-md border-0 p-2 px-4 text-sm text-black ${
className={`flex items-center justify-center gap-1 rounded-md border-0 p-2 px-4 text-sm text-black ${
isCopied
? 'bg-green-300 hover:bg-green-300'
: 'bg-gray-200 hover:bg-gray-300'
@@ -44,7 +44,7 @@ export function EmptyFriends(props: EmptyFriendsProps) {
copyText(befriendUrl);
}}
>
<img src={CopyIcon} className="h-4 w-4" alt="Invite Friends" />
<img src={CopyIcon.src} className="h-4 w-4" alt="Invite Friends" />
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'preact/hooks';
import { useState } from 'react';
import type { ListFriendsResponse } from './FriendsPage';
import { DeleteUserIcon } from '../ReactIcons/DeleteUserIcon';
import { pageProgressMessage } from '../../stores/page';
@@ -10,7 +10,10 @@ import { AddUserIcon } from '../ReactIcons/AddUserIcon';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (resourceId: string) => void;
onShowResourceProgress: (
resourceId: string,
isCustomResource?: boolean
) => void;
onReload: () => void;
};
@@ -52,7 +55,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
onReload();
}
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
const roadmaps = (friend?.roadmaps || []).sort((a, b) => {
return b.done - a.done;
});
@@ -86,7 +89,12 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
return (
<button
onClick={() => onShowResourceProgress(progress.resourceId)}
onClick={() =>
onShowResourceProgress(
progress.resourceId,
progress.isCustomResource
)
}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
key={progress.resourceId}
>
@@ -177,12 +185,12 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
<div
className={'flex w-full flex-grow items-center justify-center'}
>
<span class=" flex flex-col items-center text-red-500">
<span className=" flex flex-col items-center text-red-500">
<DeleteUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Rejected
</span>
</div>
<span class="flex cursor-default items-center justify-center border-t py-2 text-center text-sm">
<span className="flex cursor-default items-center justify-center border-t py-2 text-center text-sm">
Changed your mind?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
@@ -205,12 +213,12 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
<div
className={'flex w-full flex-grow items-center justify-center'}
>
<span class=" flex flex-col items-center text-sm text-red-500">
<span className=" flex flex-col items-center text-sm text-red-500">
<DeleteUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Rejected
</span>
</div>
<span class="flex cursor-default items-center justify-center border-t py-2.5 text-center text-sm">
<span className="flex cursor-default items-center justify-center border-t py-2.5 text-center text-sm">
<button
className="ml-2 flex items-center font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
@@ -233,7 +241,7 @@ export function FriendProgressItem(props: FriendProgressItemProps) {
<div
className={'flex w-full flex-grow items-center justify-center'}
>
<span class=" flex flex-col items-center text-green-500">
<span className=" flex flex-col items-center text-green-500">
<AddedUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Sent
</span>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { useAuth } from '../../hooks/use-auth';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
@@ -16,6 +16,7 @@ type FriendResourceProgress = {
title: string;
resourceId: string;
resourceType: string;
isCustomResource: boolean;
learning: number;
skipped: number;
done: number;
@@ -52,6 +53,7 @@ export function FriendsPage() {
const [showFriendProgress, setShowFriendProgress] = useState<{
resourceId: string;
friend: ListFriendsResponse[0];
isCustomResource?: boolean;
}>();
const [isLoading, setIsLoading] = useState(true);
@@ -120,6 +122,7 @@ export function FriendsPage() {
resourceId={showFriendProgress.resourceId}
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
isCustomResource={showFriendProgress.isCustomResource}
/>
)}
@@ -133,6 +136,7 @@ export function FriendsPage() {
return (
<button
key={grouping.value}
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
selectedGrouping === grouping.value
? ' border-gray-400 bg-gray-200 '
@@ -154,7 +158,7 @@ export function FriendsPage() {
onClick={() => {
setShowInviteFriendPopup(true);
}}
class="flex items-center justify-center gap-1.5 rounded-md border border-gray-400 bg-gray-50 p-1 px-2 text-sm hover:border-gray-500 hover:bg-gray-100"
className="flex items-center justify-center gap-1.5 rounded-md border border-gray-400 bg-gray-50 p-1 px-2 text-sm hover:border-gray-500 hover:bg-gray-100"
>
<AddUserIcon additionalClasses="w-4 h-4" />
Invite Friends
@@ -166,10 +170,11 @@ export function FriendsPage() {
{filteredFriends.map((friend) => (
<FriendProgressItem
friend={friend}
onShowResourceProgress={(resourceId) => {
onShowResourceProgress={(resourceId, isCustomResource) => {
setShowFriendProgress({
resourceId,
friend,
isCustomResource,
});
}}
key={friend.userId}
@@ -187,7 +192,7 @@ export function FriendsPage() {
{filteredFriends.length === 0 && (
<div className="flex flex-col items-center justify-center py-12">
<img
src={UserIcon}
src={UserIcon.src}
alt="Empty Friends"
className="mb-3 w-12 opacity-20"
/>

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'preact/hooks';
import type { MouseEvent } from 'react';
import { useRef } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import CopyIcon from '../../icons/copy.svg';
import { useCopyText } from '../../hooks/use-copy-text';
@@ -22,13 +23,13 @@ export function InviteFriendPopup(props: InviteFriendPopupProps) {
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyRef}
class="popup-body relative rounded-lg bg-white p-4 shadow"
className="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h3 class="mb-1.5 text-xl font-medium sm:text-2xl">Invite URL</h3>
<h3 className="mb-1.5 text-xl font-medium sm:text-2xl">Invite URL</h3>
<p className="mb-3 hidden text-sm leading-none text-gray-400 sm:block">
Share the link below with your friends to invite them.
</p>
@@ -38,13 +39,13 @@ export function InviteFriendPopup(props: InviteFriendPopupProps) {
readOnly={true}
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
value={befriendUrl}
onClick={(e) => {
e?.target?.select();
onClick={(e: MouseEvent<HTMLInputElement>) => {
(e?.target as HTMLInputElement)?.select();
copyText(befriendUrl);
}}
/>
<button
class={`flex items-center justify-center gap-1 rounded-md border-0 px-3 py-2.5 text-sm text-black ${
className={`flex items-center justify-center gap-1 rounded-md border-0 px-3 py-2.5 text-sm text-black ${
isCopied
? 'bg-green-300 hover:bg-green-300'
: 'bg-gray-200 hover:bg-gray-300'
@@ -53,7 +54,11 @@ export function InviteFriendPopup(props: InviteFriendPopupProps) {
copyText(befriendUrl);
}}
>
<img src={CopyIcon} className="h-4 w-4" alt="Invite Friends" />
<img
src={CopyIcon.src}
className="h-4 w-4"
alt="Invite Friends"
/>
{isCopied ? 'Copied' : 'Copy URL'}
</button>
</div>

View File

@@ -1,6 +1,5 @@
import { httpGet } from '../../lib/http';
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
type GetFriendCountsResponse = {
sentCount: number;
@@ -30,16 +29,11 @@ export function SidebarFriendsCounter() {
const pendingCount = friendCounts?.receivedCount || 0;
if (!pendingCount) {
return (
<span class="relative mr-1 flex items-center">
<span class="relative rounded-full bg-gray-200 p-1 text-xs" />
<span class="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
</span>
);
return null;
}
return (
<span class="flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
{pendingCount}
</span>
);

View File

@@ -13,18 +13,25 @@ const { url, title, description, isNew } = Astro.props;
<a
href={url}
class='bg-gradient-to-r from-slate-900 to-amber-900 hover:from-stone-900 hover:to-stone-900 hover:bg-gray-100 flex flex-col p-2.5 sm:p-5 rounded-md sm:rounded-lg border border-gray-200 relative h-full'
class='relative flex h-full flex-col rounded-md border border-gray-200 bg-white p-2.5 hover:border-gray-400 sm:rounded-lg sm:p-5'
>
<span
class='font-regular sm:font-medium text-md sm:text-xl hover:text-gray-50 text-gray-200 sm:text-gray-100 mb-0 sm:mb-1.5'
class='text-md mb-0 font-semibold text-gray-900 hover:text-black sm:mb-1.5 sm:text-xl'
>
{title}
</span>
<span class='text-sm leading-normal text-gray-400 hidden sm:block'>{description}</span>
<span
class='hidden text-sm leading-normal text-gray-400 sm:block'
set:html={description}
/>
{
isNew && (
<span class='absolute bottom-1 right-1 bg-yellow-300 text-yellow-900 text-xs font-medium px-1 sm:px-1.5 py-0.5 rounded-sm uppercase'>
<span class='flex items-center gap-1.5 absolute bottom-1.5 right-1 rounded-sm text-xs font-semibold uppercase text-purple-500 sm:px-1.5'>
<span class='relative flex h-2 w-2'>
<span class='absolute inline-flex h-full w-full animate-ping rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
</span>
New
</span>
)

View File

@@ -1,7 +1,8 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { ProgressList } from './ProgressList';
import { HeroRoadmaps } from './HeroRoadmaps';
import { isLoggedIn } from '../../lib/jwt';
export type UserProgressResponse = {
resourceId: string;
@@ -13,6 +14,7 @@ export type UserProgressResponse = {
skipped: number;
total: number;
updatedAt: Date;
isCustomResource: boolean;
}[];
function renderProgress(progressList: UserProgressResponse) {
@@ -47,10 +49,17 @@ function renderProgress(progressList: UserProgressResponse) {
});
}
type ProgressResponse = UserProgressResponse;
export function FavoriteRoadmaps() {
const isAuthenticated = isLoggedIn();
if (!isAuthenticated) {
return null;
}
const [isPreparing, setIsPreparing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState<UserProgressResponse>([]);
const [progress, setProgress] = useState<ProgressResponse>([]);
const [containerOpacity, setContainerOpacity] = useState(0);
function showProgressContainer() {
@@ -73,10 +82,9 @@ export function FavoriteRoadmaps() {
async function loadProgress() {
setIsLoading(true);
const { response: progressList, error } =
await httpGet<UserProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
);
const { response: progressList, error } = await httpGet<ProgressResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-hero-roadmaps`
);
if (error || !progressList) {
return;
@@ -105,18 +113,25 @@ export function FavoriteRoadmaps() {
return null;
}
const hasProgress = progress.length > 0;
const hasProgress = progress?.length > 0;
const customRoadmaps = progress?.filter((p) => p.isCustomResource);
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
return (
<div
class={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
className={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
>
<div className="container min-h-full">
{!isLoading && progress.length == 0 && <EmptyProgress />}
{progress.length > 0 && (
<ProgressList progress={progress} isLoading={isLoading} />
{!isLoading && progress?.length == 0 && <EmptyProgress />}
{hasProgress && (
<HeroRoadmaps
showCustomRoadmaps={true}
customRoadmaps={customRoadmaps}
progress={defaultRoadmaps}
isLoading={isLoading}
/>
)}
</div>
</div>

View File

@@ -0,0 +1,169 @@
import type { UserProgressResponse } from './FavoriteRoadmaps';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { MapIcon } from 'lucide-react';
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
import { useState } from 'react';
type ProgressRoadmapProps = {
url: string;
percentageDone: number;
allowFavorite?: boolean;
resourceId: string;
resourceType: ResourceType;
resourceTitle: string;
isFavorite?: boolean;
};
function HeroRoadmap(props: ProgressRoadmapProps) {
const {
url,
percentageDone,
resourceType,
resourceId,
resourceTitle,
isFavorite,
allowFavorite = true,
} = props;
return (
<a
href={url}
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
>
<span className="relative z-20">{resourceTitle}</span>
<span
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }}
></span>
{allowFavorite && (
<MarkFavorite
resourceId={resourceId}
resourceType={resourceType}
favorite={isFavorite}
/>
)}
</a>
);
}
type ProgressTitleProps = {
icon: any;
isLoading?: boolean;
title: string;
};
export function HeroTitle(props: ProgressTitleProps) {
const { isLoading = false, title, icon } = props;
return (
<p className="mb-4 flex items-center text-sm text-gray-400">
{!isLoading && icon}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
{title}
</p>
);
}
type ProgressListProps = {
progress: UserProgressResponse;
customRoadmaps: UserProgressResponse;
isLoading?: boolean;
};
export function HeroRoadmaps(props: ProgressListProps) {
const { progress, isLoading = false, customRoadmaps } = props;
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
return (
<div className="relative pb-12 pt-4 sm:pt-7">
{isCreatingRoadmap && (
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
)}
{
<HeroTitle
icon={
(<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />) as any
}
isLoading={isLoading}
title="Your progress and favorite roadmaps."
/>
}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => (
<HeroRoadmap
key={resource.resourceId}
resourceId={resource.resourceId}
resourceType={resource.resourceType}
resourceTitle={resource.resourceTitle}
isFavorite={resource.isFavorite}
percentageDone={
((resource.skipped + resource.done) / resource.total) * 100
}
url={
resource.resourceType === 'roadmap'
? `/${resource.resourceId}`
: `/best-practices/${resource.resourceId}`
}
/>
))}
</div>
<div className="mt-5">
{
<HeroTitle
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
title="Your custom roadmaps"
/>
}
{customRoadmaps.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
You haven't created any custom roadmaps yet.{' '}
<button
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
onClick={() => setIsCreatingRoadmap(true)}
>
Create one!
</button>
</p>
)}
{customRoadmaps.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{customRoadmaps.map((customRoadmap) => {
return (
<HeroRoadmap
key={customRoadmap.resourceId}
resourceId={customRoadmap.resourceId}
resourceType={'roadmap'}
resourceTitle={customRoadmap.resourceTitle}
percentageDone={
((customRoadmap.skipped + customRoadmap.done) /
customRoadmap.total) *
100
}
url={`/r?id=${customRoadmap.resourceId}`}
allowFavorite={false}
/>
);
})}
<CreateRoadmapButton />
</div>
)}
</div>
</div>
);
}

View File

@@ -24,5 +24,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
their career.
</p>
</div>
<FavoriteRoadmaps client:authenticated />
<FavoriteRoadmaps client:only="react" />
</div>

View File

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

46
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { type ReactNode, useRef } from 'react';
import { useOutsideClick } from '../hooks/use-outside-click';
import { useKeydown } from '../hooks/use-keydown';
import { cn } from '../lib/classname';
type ModalProps = {
onClose: () => void;
children: ReactNode;
bodyClassName?: string;
wrapperClassName?: string;
};
export function Modal(props: ModalProps) {
const { onClose, children, bodyClassName, wrapperClassName } = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
useKeydown('Escape', () => {
onClose();
});
useOutsideClick(popupBodyEl, () => {
onClose();
});
return (
<div className="popup fixed left-0 right-0 top-0 z-[99] flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div
className={cn(
'relative h-full w-full max-w-md p-4 md:h-auto',
wrapperClassName
)}
>
<div
ref={popupBodyEl}
className={cn(
'popup-body relative h-full rounded-lg bg-white shadow',
bodyClassName
)}
>
{children}
</div>
</div>
</div>
);
}

View File

@@ -4,12 +4,12 @@ import Icon from '../AstroIcon.astro';
<div class='relative hidden' data-auth-required>
<button
class='flex h-8 w-28 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600'
class='flex h-8 w-38 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600'
type='button'
data-account-button
>
<span class='inline-flex items-center gap-1.5'>
Account
Account <span class="text-gray-300">/</span> Teams
<Icon
icon='chevron-down'
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'

View File

@@ -0,0 +1,52 @@
import { useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { isLoggedIn } from '../../lib/jwt';
import { AccountDropdownList } from './AccountDropdownList';
import { DropdownTeamList } from './DropdownTeamList';
import { useOutsideClick } from '../../hooks/use-outside-click';
export function AccountDropdown() {
const dropdownRef = useRef(null);
const [showDropdown, setShowDropdown] = useState(false);
const [isTeamsOpen, setIsTeamsOpen] = useState(false);
useOutsideClick(dropdownRef, () => {
setShowDropdown(false);
setIsTeamsOpen(false);
});
if (!isLoggedIn()) {
return null;
}
return (
<div className="relative z-50 animate-fade-in">
<button
className="flex h-8 w-40 items-center justify-center gap-1.5 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600"
onClick={() => {
setIsTeamsOpen(false);
setShowDropdown(!showDropdown);
}}
>
<span className="inline-flex items-center">
Account&nbsp;<span className="text-gray-300">/</span>&nbsp;Teams
</span>
<ChevronDown className="h-4 w-4 shrink-0 stroke-[2.5px]" />
</button>
{showDropdown && (
<div
ref={dropdownRef}
className="absolute right-0 z-50 mt-2 min-h-[152px] w-48 rounded-md bg-slate-800 py-1 shadow-xl"
>
{isTeamsOpen ? (
<DropdownTeamList setIsTeamsOpen={setIsTeamsOpen} />
) : (
<AccountDropdownList setIsTeamsOpen={setIsTeamsOpen} />
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { ChevronRight } from 'lucide-react';
import { logout } from './navigation';
type AccountDropdownListProps = {
setIsTeamsOpen: (isOpen: boolean) => void;
};
export function AccountDropdownList(props: AccountDropdownListProps) {
const { setIsTeamsOpen } = props;
return (
<ul>
<li className="px-1">
<a
href="/account"
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
Profile
</a>
</li>
<li className="px-1">
<a
href="/account/friends"
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
Friends
</a>
</li>
<li className="px-1">
<button
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
onClick={() => setIsTeamsOpen(true)}
>
Teams
<ChevronRight className="h-4 w-4 shrink-0 stroke-[2.5px] text-slate-400 group-hover:text-white" />
</button>
</li>
<li className="px-1">
<button
className="block w-full rounded pl-4 pr-2 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700"
type="button"
onClick={logout}
>
Logout
</button>
</li>
</ul>
);
}

View File

@@ -0,0 +1,110 @@
import { ChevronLeft, Loader2, Plus, Users } from 'lucide-react';
import { $teamList } from '../../stores/team';
import { httpGet } from '../../lib/http';
import type { TeamListResponse } from '../TeamDropdown/TeamDropdown';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/react';
import { useEffect, useState } from 'react';
import { Spinner } from '../ReactIcons/Spinner';
type DropdownTeamListProps = {
setIsTeamsOpen: (isOpen: boolean) => void;
};
export function DropdownTeamList(props: DropdownTeamListProps) {
const { setIsTeamsOpen } = props;
const toast = useToast();
const teamList = useStore($teamList);
const [isLoading, setIsLoading] = useState(true);
async function getAllTeams() {
if (teamList.length > 0) {
return;
}
setIsLoading(true);
const { response, error } = await httpGet<TeamListResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-teams`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
$teamList.set(response);
}
useEffect(() => {
getAllTeams().finally(() => setIsLoading(false));
}, []);
const loadingIndicator = isLoading && (
<div className="mt-2 flex animate-pulse flex-col gap-1 px-1 text-center">
<div className="h-[35px] rounded-md bg-gray-700"></div>
<div className="h-[35px] rounded-md bg-gray-700"></div>
<div className="h-[35px] rounded-md bg-gray-700"></div>
</div>
);
return (
<>
<div className="flex items-center justify-between px-2">
<button
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
onClick={() => setIsTeamsOpen(false)}
>
<ChevronLeft className="h-4 w-4 stroke-[2.5px]" />
</button>
<a
className="mt-1 flex h-5 w-5 items-center justify-center rounded text-slate-400 hover:bg-slate-50/10 hover:text-slate-50"
href="/team/new"
>
<Plus className="h-4 w-4 stroke-[2.5px]" />
</a>
</div>
{loadingIndicator}
{!isLoading && (
<ul className="mt-2">
{teamList?.map((team) => {
let pageLink = '';
if (team.status === 'invited') {
pageLink = `/respond-invite?i=${team.memberId}`;
} else if (team.status === 'joined') {
pageLink = `/team/progress?t=${team._id}`;
}
return (
<li key={team._id} className="px-1">
<a
href={pageLink}
className="block truncate rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
>
{team.name}
</a>
</li>
);
})}
{teamList.length === 0 && !isLoading && (
<li className="mt-2 px-1 text-center">
<p className="block rounded px-4 py-2 text-sm font-medium text-slate-500">
<Users className="mx-auto mb-2 h-7 w-7 text-slate-600" />
No teams found.{' '}
<a
className="font-medium text-slate-400 underline underline-offset-2 hover:text-slate-300"
href="/team/new"
>
Create a team
</a>
.
</p>
</li>
)}
</ul>
)}
</>
);
}

View File

@@ -1,6 +1,6 @@
---
import Icon from '../AstroIcon.astro';
import AccountDropdown from './AccountDropdown.astro';
import { AccountDropdown } from './AccountDropdown';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
@@ -24,10 +24,11 @@ import AccountDropdown from './AccountDropdown.astro';
>
</li>
<li class='hidden lg:inline'>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
>
</li>
<li class='hidden lg:inline'>
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
</li>
<li>
<kbd
@@ -44,7 +45,7 @@ import AccountDropdown from './AccountDropdown.astro';
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
</li>
<li>
<AccountDropdown />
<AccountDropdown client:only="react" />
<a
data-guest-required
@@ -108,6 +109,11 @@ import AccountDropdown from './AccountDropdown.astro';
Account
</a>
</li>
<li data-auth-required class='hidden'>
<a href='/team' class='text-xl hover:text-blue-300 md:text-lg'>
Teams
</a>
</li>
<li data-auth-required class='hidden'>
<button
data-logout-button

View File

@@ -2,12 +2,9 @@ import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function logout() {
Cookies.remove(TOKEN_COOKIE_NAME);
// @FIXME: fix the domain name in the cookie created by the API
Cookies.remove(TOKEN_COOKIE_NAME, {
path: '/',
domain: 'api.roadmap.sh',
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
// Reloading will automatically redirect the user if required
@@ -22,6 +19,8 @@ function bindEvents() {
...target.closest('button')?.dataset,
};
const accountDropdown = document.querySelector('[data-account-dropdown]');
// If the user clicks on the logout button, remove the token cookie
if (dataset.logoutButton !== undefined) {
e.preventDefault();
@@ -30,6 +29,12 @@ function bindEvents() {
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
} else if (dataset.closeMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
} else if (
accountDropdown &&
!target?.closest('[data-account-dropdown]') &&
!accountDropdown.classList.contains('hidden')
) {
accountDropdown.classList.add('hidden');
}
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import { httpGet, httpPatch, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
@@ -62,7 +62,7 @@ export function NotificationPage() {
return (
<div>
<div class="mb-8 hidden md:block">
<div className="mb-8 hidden md:block">
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
<p className="mt-2 text-gray-400">Manage your notifications</p>
</div>
@@ -91,14 +91,14 @@ export function NotificationPage() {
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('accept', notification?._id!)}
>
<img src={AcceptIcon} className="h-4 w-4" />
<img src={AcceptIcon.src} className="h-4 w-4" />
</button>
<button type="button"
disabled={isLoading}
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('reject', notification?._id!)}
>
<img src={XIcon} className="h-4 w-4" />
<img alt={'Close'} src={XIcon.src} className="h-4 w-4" />
</button>
</div>
</div>

View File

@@ -6,8 +6,8 @@ const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
---
<div class='py-6 sm:py-16 border-b border-t text-left sm:text-center bg-white'>
<div class='max-w-[600px] container'>
<h2 class='text-2xl sm:text-5xl font-bold'>Community</h2>
<div class='!max-w-[600px] container'>
<p class='text-2xl sm:text-5xl font-bold'>Community</p>
<p class='text-gray-600 text-sm sm:text-lg leading-relaxed my-2.5 sm:my-5'>
roadmap.sh is the <a
href='https://github.com/search?o=desc&q=stars%3A%3E100000&s=stars&type=Repositories'

View File

@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/preact';
import { useStore } from '@nanostores/react';
import SpinnerIcon from '../icons/spinner.svg';
import { pageProgressMessage } from '../stores/page';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
export interface Props {
initialMessage: string;
@@ -29,9 +29,9 @@ export function PageProgress(props: Props) {
<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 ">
<div className="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
<img
src={SpinnerIcon}
src={SpinnerIcon.src}
alt="Loading"
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
/>

View File

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from 'react';
import CloseIcon from '../icons/close.svg';
import { httpGet } from '../lib/http';
import { sponsorHidden } from '../stores/page';
import { useStore } from '@nanostores/react';
export type PageSponsorType = {
company: string;
@@ -77,15 +77,14 @@ export function PageSponsor(props: PageSponsorProps) {
return null;
}
const { url, title, imageUrl, description, company, gaLabel, pageUrl } =
sponsor;
const { url, title, imageUrl, description, company, gaLabel } = 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"
className="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
onClick={() => {
window.fireEvent({
category: 'SponsorClick',
@@ -95,28 +94,26 @@ export function PageSponsor(props: PageSponsorProps) {
}}
>
<span
class="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
className="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} />
<img alt="Close" className="h-4 w-4" src={CloseIcon.src} />
</span>
<img
src={imageUrl}
class="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
className="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 className="flex flex-1 flex-col justify-between text-sm">
<span className="p-[10px]">
<span className="mb-0.5 block font-semibold">{title}</span>
<span className="block text-gray-500">{description}</span>
</span>
<span class="sponsor-footer">Partner Content</span>
<span className="sponsor-footer">Partner Content</span>
</span>
</a>
);

View File

@@ -5,9 +5,9 @@ import Popup from './Popup/Popup.astro';
<Popup id='progress-help' title='' subtitle=''>
<div class='-mt-2.5'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
<p class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
Track your Progress
</h2>
</p>
<p class='text-sm leading-4 text-gray-600'>
Login and use one of the options listed below.
</p>

View File

@@ -0,0 +1,142 @@
/**
* atom-dark theme for `prism.js`
* Based on Atom's `atom-dark` theme: https://github.com/atom/atom-dark-syntax
* @author Joe Gibson (@gibsjose)
*/
code[class*="language-"],
pre[class*="language-"] {
color: #c5c8c6;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Inconsolata, Monaco, Consolas, 'Courier New', Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #1d1f21;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #7C7C7C;
}
.token.punctuation {
color: #c5c8c6;
}
.namespace {
opacity: .7;
}
.token.property,
.token.keyword,
.token.tag {
color: #96CBFE;
}
.token.class-name {
color: #FFFFB6;
text-decoration: underline;
}
.token.boolean,
.token.constant {
color: #99CC99;
}
.token.symbol,
.token.deleted {
color: #f92672;
}
.token.number {
color: #FF73FD;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #A8FF60;
}
.token.variable {
color: #C6C5FE;
}
.token.operator {
color: #EDEDED;
}
.token.entity {
color: #FFFFB6;
cursor: help;
}
.token.url {
color: #96CBFE;
}
.language-css .token.string,
.style .token.string {
color: #87C38A;
}
.token.atrule,
.token.attr-value {
color: #F9EE98;
}
.token.function {
color: #DAD085;
}
.token.regex {
color: #E9C062;
}
.token.important {
color: #fd971f;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}

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