mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 10:11:55 +08:00
Compare commits
396 Commits
feat/linke
...
chore/java
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf5aee2f5 | ||
|
|
aee10fac37 | ||
|
|
c70ee5c5f3 | ||
|
|
b39de5f670 | ||
|
|
802b84ad79 | ||
|
|
ff000c87ed | ||
|
|
1604cb9d8c | ||
|
|
3fab75d44c | ||
|
|
7fb089259d | ||
|
|
6713b059e1 | ||
|
|
e9651c6afe | ||
|
|
96fe0a5439 | ||
|
|
0393a658a7 | ||
|
|
4d0143f137 | ||
|
|
66eff7af70 | ||
|
|
0331e1f782 | ||
|
|
0318fe48e3 | ||
|
|
00ba8a73c1 | ||
|
|
81a9baedd0 | ||
|
|
50e26e4fe2 | ||
|
|
56473b129c | ||
|
|
1dd53d8994 | ||
|
|
1b639c433c | ||
|
|
041facdc61 | ||
|
|
e4d770e256 | ||
|
|
81bbb42e34 | ||
|
|
b92ae9b836 | ||
|
|
83df0da6b4 | ||
|
|
a58b78bfe9 | ||
|
|
2fa41f583e | ||
|
|
80819f8914 | ||
|
|
edcf0e683d | ||
|
|
aa6d48b775 | ||
|
|
3e622ecc2c | ||
|
|
ea5c3c2c01 | ||
|
|
8dc0424823 | ||
|
|
f3b16eb50f | ||
|
|
e07112a3a9 | ||
|
|
81983b6b06 | ||
|
|
bc6b100c26 | ||
|
|
846bbc1533 | ||
|
|
0b0168b40f | ||
|
|
4c9371ee74 | ||
|
|
bb9cc31e8a | ||
|
|
8585857cc3 | ||
|
|
8c2e812667 | ||
|
|
bfbee6da0f | ||
|
|
8057b218a0 | ||
|
|
c3d24a65d1 | ||
|
|
67beb4e8c4 | ||
|
|
35066d5b70 | ||
|
|
bb76ae411f | ||
|
|
98ea93da8c | ||
|
|
a69f0cc1b1 | ||
|
|
e50e75479a | ||
|
|
f4592b1e58 | ||
|
|
45c88da643 | ||
|
|
a54fe0d1ba | ||
|
|
e1f494776e | ||
|
|
11272da330 | ||
|
|
8903f11f02 | ||
|
|
8ca9f976cd | ||
|
|
488521d2e3 | ||
|
|
072953c69a | ||
|
|
79a656e171 | ||
|
|
b565ce9bce | ||
|
|
460ea8b95a | ||
|
|
26ab7b9098 | ||
|
|
0eebcd03a4 | ||
|
|
9c75404d0c | ||
|
|
61c3c88fb6 | ||
|
|
1ed54bad90 | ||
|
|
437d879af3 | ||
|
|
58dd3f2f41 | ||
|
|
cbe758349c | ||
|
|
a847d0b08d | ||
|
|
548b7f31f9 | ||
|
|
2e18d5a563 | ||
|
|
5bbcd85e6c | ||
|
|
1eb0e8869a | ||
|
|
1b74e86db7 | ||
|
|
07b2cb0f9b | ||
|
|
fba926625d | ||
|
|
e4c29b03ab | ||
|
|
2a7fd53c8b | ||
|
|
4cb905b69a | ||
|
|
a123fc0828 | ||
|
|
e15a36a2ce | ||
|
|
ca32c814da | ||
|
|
c4ef2bfcb4 | ||
|
|
bb42c809fb | ||
|
|
03d0a32fd6 | ||
|
|
b8c90948f9 | ||
|
|
5c57a84e82 | ||
|
|
c274feced1 | ||
|
|
cdb9153029 | ||
|
|
d3bebfeea6 | ||
|
|
68f9e4576b | ||
|
|
051bcce933 | ||
|
|
e3793b00c7 | ||
|
|
f256a5a9b0 | ||
|
|
7e18c97e78 | ||
|
|
c95caccae5 | ||
|
|
102c57e925 | ||
|
|
1ec6005fe1 | ||
|
|
ce41b3a955 | ||
|
|
eea79968e2 | ||
|
|
538e41307c | ||
|
|
79fcf2400f | ||
|
|
0da1edaa55 | ||
|
|
b04b8c702f | ||
|
|
1a7a6db50c | ||
|
|
7072c4cf80 | ||
|
|
f2b29f80f9 | ||
|
|
76c2686269 | ||
|
|
b7728fa6fd | ||
|
|
ca5bae687b | ||
|
|
face1eefbb | ||
|
|
498ef2eb3b | ||
|
|
80d53a9c5d | ||
|
|
e0eccaa30e | ||
|
|
c43ee13c94 | ||
|
|
b57c4cb558 | ||
|
|
c236bf9bf9 | ||
|
|
c92c67acc9 | ||
|
|
bec59ed630 | ||
|
|
7f800f2717 | ||
|
|
e5579ef7d1 | ||
|
|
9e5baad85f | ||
|
|
146022d1ed | ||
|
|
6af8033764 | ||
|
|
e4d6cd9f41 | ||
|
|
5cff162a94 | ||
|
|
3b7e5d5ce2 | ||
|
|
6bc7c2f48c | ||
|
|
458396f782 | ||
|
|
bb7f1f4d67 | ||
|
|
430350fe88 | ||
|
|
c1d37dead3 | ||
|
|
eafd36f6aa | ||
|
|
ea70632de1 | ||
|
|
08e29c2c14 | ||
|
|
00b27eabd6 | ||
|
|
667e7f4c7f | ||
|
|
19edadcc18 | ||
|
|
c5cb2e1877 | ||
|
|
3a09982ff6 | ||
|
|
1d716a9438 | ||
|
|
b69889cc29 | ||
|
|
92295a7906 | ||
|
|
2c1ab6b19d | ||
|
|
fb3fe8be42 | ||
|
|
c3b34cde3f | ||
|
|
a30cb170d6 | ||
|
|
0a5eeae68c | ||
|
|
9ed60d836a | ||
|
|
c720888f2b | ||
|
|
2018b9bf38 | ||
|
|
1ca36e8bfa | ||
|
|
c0e2e541ca | ||
|
|
04478272c2 | ||
|
|
9c2e9c1be6 | ||
|
|
77310d24d8 | ||
|
|
6524da9a9a | ||
|
|
c1d39d24db | ||
|
|
8a747acabd | ||
|
|
18caaa9d0a | ||
|
|
c066ba6c52 | ||
|
|
35148cb8a3 | ||
|
|
5b541dfb3d | ||
|
|
fc8ce296be | ||
|
|
543d3b47ce | ||
|
|
21008de3d1 | ||
|
|
8787ed46c5 | ||
|
|
94ad20fc04 | ||
|
|
7f5bbf743a | ||
|
|
f48a351c99 | ||
|
|
b85639d876 | ||
|
|
14f9ad9530 | ||
|
|
076b866430 | ||
|
|
7aca57c3e4 | ||
|
|
36cd03f14f | ||
|
|
5bc33cb527 | ||
|
|
5d3202e065 | ||
|
|
5cf286a753 | ||
|
|
0addc56123 | ||
|
|
3182e2a599 | ||
|
|
8c7fb8cab5 | ||
|
|
f61d360ee7 | ||
|
|
29d91be094 | ||
|
|
8ee56576ea | ||
|
|
8e945f5e1c | ||
|
|
ac48f4c441 | ||
|
|
34d0cde165 | ||
|
|
03ba0c384b | ||
|
|
bbe8125fc1 | ||
|
|
0c64223ec1 | ||
|
|
3a022926de | ||
|
|
93a6ae3f81 | ||
|
|
42b3595367 | ||
|
|
39278cc97b | ||
|
|
c83d20d63c | ||
|
|
6e8770c8c4 | ||
|
|
3457f7495a | ||
|
|
07acb17459 | ||
|
|
77cd0ecf26 | ||
|
|
eccc0302f2 | ||
|
|
7274d8a54e | ||
|
|
8d19be6232 | ||
|
|
e0828d11bf | ||
|
|
9e7a37d079 | ||
|
|
76f1592615 | ||
|
|
80e80e7d9b | ||
|
|
8692f05f14 | ||
|
|
e5705bd6cc | ||
|
|
f52e6df410 | ||
|
|
c4db994753 | ||
|
|
7bfd3934f8 | ||
|
|
32dac79565 | ||
|
|
ceb51a18df | ||
|
|
c21f217425 | ||
|
|
9299326dc2 | ||
|
|
fbe597706a | ||
|
|
c7b6257c74 | ||
|
|
dbe6f8589d | ||
|
|
9139c8eaf8 | ||
|
|
05451a0f07 | ||
|
|
36d4d8e449 | ||
|
|
fa8551dd31 | ||
|
|
7cbf8eb72a | ||
|
|
e739662d49 | ||
|
|
e26fa35470 | ||
|
|
37e92fd084 | ||
|
|
0aef3efda9 | ||
|
|
7187da853b | ||
|
|
b81dba9f8b | ||
|
|
bf0fd62bff | ||
|
|
67e6043cbc | ||
|
|
9d169219ce | ||
|
|
8eb6a0f857 | ||
|
|
9c2d3bd2d8 | ||
|
|
d6de73d7d4 | ||
|
|
8899654937 | ||
|
|
d64cb4116a | ||
|
|
f428849daa | ||
|
|
83143f4438 | ||
|
|
8adc6cb7b4 | ||
|
|
d12eccb6aa | ||
|
|
93a1dedd8f | ||
|
|
027a4a947a | ||
|
|
67fd8d3d47 | ||
|
|
e42532ad7c | ||
|
|
944a35a905 | ||
|
|
9f620866cb | ||
|
|
4d74e9c47c | ||
|
|
f1a37deab2 | ||
|
|
f36dd4b964 | ||
|
|
80c842412a | ||
|
|
219ef68001 | ||
|
|
ab8a551a96 | ||
|
|
467382879d | ||
|
|
258f6cd0f0 | ||
|
|
2c3bf1ebbc | ||
|
|
1113b698be | ||
|
|
eefcc6866b | ||
|
|
34185ac8fb | ||
|
|
c1e85ce422 | ||
|
|
6ed270112d | ||
|
|
df4c457dd4 | ||
|
|
8a5bc21206 | ||
|
|
2d3a89bd56 | ||
|
|
d39dad7275 | ||
|
|
37107c495f | ||
|
|
2a910ddde4 | ||
|
|
11d7e7d431 | ||
|
|
991de00891 | ||
|
|
7747582e70 | ||
|
|
28550ec84c | ||
|
|
8246b48f59 | ||
|
|
455a70c64c | ||
|
|
f0f797a996 | ||
|
|
037763770d | ||
|
|
8d4299c899 | ||
|
|
534ed126d4 | ||
|
|
0fa6ecd3ce | ||
|
|
7dfb630cb5 | ||
|
|
13e1aacd3b | ||
|
|
9ad5143588 | ||
|
|
9e867d5f4e | ||
|
|
f3b186d525 | ||
|
|
5f9a50804b | ||
|
|
486603aff7 | ||
|
|
feec4b7576 | ||
|
|
f64f7b973e | ||
|
|
31f941e262 | ||
|
|
09d312ee46 | ||
|
|
a92e8f1b1a | ||
|
|
bca66f7c0b | ||
|
|
b743a31610 | ||
|
|
b1dc116cae | ||
|
|
fae57224a8 | ||
|
|
c8ffea31d9 | ||
|
|
fc3b2a4015 | ||
|
|
f70272763f | ||
|
|
a15c2a3ca7 | ||
|
|
550555c0c5 | ||
|
|
6e201a8c29 | ||
|
|
dd139170d1 | ||
|
|
66412327fa | ||
|
|
7736271ba0 | ||
|
|
4236c8495a | ||
|
|
6c930716fc | ||
|
|
522b00612a | ||
|
|
e36ff7bdd6 | ||
|
|
d168731cbd | ||
|
|
715daf499f | ||
|
|
6f5449e4b9 | ||
|
|
7f3690d5b8 | ||
|
|
1046dc9171 | ||
|
|
28f672d989 | ||
|
|
3f5ddfa346 | ||
|
|
f1f4e99dab | ||
|
|
67f3917a8d | ||
|
|
02988fac2c | ||
|
|
d21deb0725 | ||
|
|
2a5d316e58 | ||
|
|
557a01b4d0 | ||
|
|
680dbee6eb | ||
|
|
b525c5efb4 | ||
|
|
347141f93b | ||
|
|
9a6e8b1635 | ||
|
|
8a42d0346b | ||
|
|
0e0a3f17ae | ||
|
|
9298f76a68 | ||
|
|
e85ff79dbe | ||
|
|
7a19b7887a | ||
|
|
08e7efa637 | ||
|
|
cc348c0c96 | ||
|
|
5a059c151f | ||
|
|
4063b71345 | ||
|
|
129ef9ccd8 | ||
|
|
d60e4fcfa4 | ||
|
|
310c6d4c55 | ||
|
|
fffccbe5b5 | ||
|
|
9685f1e952 | ||
|
|
ef53c2dd5f | ||
|
|
7e0f7a32af | ||
|
|
cdea68e754 | ||
|
|
90069e4ef4 | ||
|
|
8dbaa60b58 | ||
|
|
19b38dec4c | ||
|
|
9c246984d1 | ||
|
|
ff0e10c16c | ||
|
|
ec165d4a78 | ||
|
|
afe718ee09 | ||
|
|
4aca01a98d | ||
|
|
140282f1ff | ||
|
|
4d38d19e4f | ||
|
|
5e39417a64 | ||
|
|
03ec7ebcd9 | ||
|
|
fbb6def555 | ||
|
|
ae9e30eb73 | ||
|
|
9e89c6946b | ||
|
|
6ff83d0797 | ||
|
|
5ff131ae29 | ||
|
|
e80f88ef2c | ||
|
|
cff01c151b | ||
|
|
6ca85a41a2 | ||
|
|
1630b493b1 | ||
|
|
518ece3cab | ||
|
|
aba2fd1d35 | ||
|
|
fcd68568c2 | ||
|
|
1b5e9ffe0d | ||
|
|
b3c3e44ba2 | ||
|
|
67b49d3f87 | ||
|
|
0d3e1d31bb | ||
|
|
28a27a1c65 | ||
|
|
8c3ea21ef1 | ||
|
|
417596db36 | ||
|
|
28240162b3 | ||
|
|
6dca357782 | ||
|
|
d1fe06a4e9 | ||
|
|
97cba5681b | ||
|
|
715d2ba62b | ||
|
|
32673c21fb | ||
|
|
f0c47705cb | ||
|
|
612b91e05f | ||
|
|
b4cce42844 | ||
|
|
2c2d57ecab | ||
|
|
d05374ca68 | ||
|
|
b5c02a9aff | ||
|
|
1e3568a1c4 | ||
|
|
bdeebbc9cc | ||
|
|
3b7a9ca5cd | ||
|
|
b6c8260faf | ||
|
|
03f69c02c1 |
25
.github/ISSUE_TEMPLATE/01-suggest-changes.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/01-suggest-changes.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: "✍️ Suggest Changes"
|
||||
description: Help us improve the roadmaps by suggesting changes
|
||||
labels: [suggestion]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to help us improve the roadmaps with your suggestions.
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: Roadmap URL
|
||||
description: Please provide the URL of the roadmap you are suggesting changes to.
|
||||
placeholder: https://roadmap.sh
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: roadmap-suggestions
|
||||
attributes:
|
||||
label: Suggestions
|
||||
description: What changes would you like to suggest?
|
||||
placeholder: Enter your suggestions here.
|
||||
validations:
|
||||
required: true
|
||||
42
.github/ISSUE_TEMPLATE/02-bug-report.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/02-bug-report.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: Report an issue or possible bug
|
||||
labels: [bug]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: What is the URL where the issue is happening
|
||||
placeholder: https://roadmap.sh
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What browsers are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Firefox
|
||||
- Chrome
|
||||
- Safari
|
||||
- Microsoft Edge
|
||||
- Other
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Output from browser console (if any)
|
||||
description: Please copy and paste any relevant log output.
|
||||
- type: checkboxes
|
||||
id: will-pr
|
||||
attributes:
|
||||
label: Participation
|
||||
options:
|
||||
- label: I am willing to submit a pull request for this issue.
|
||||
required: false
|
||||
12
.github/ISSUE_TEMPLATE/03-feature-suggestion.yml
vendored
Normal file
12
.github/ISSUE_TEMPLATE/03-feature-suggestion.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: "✨ Feature Suggestion"
|
||||
description: Is there a feature you'd like to see on Roadmap.sh? Let us know!
|
||||
labels: [feature request]
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: Please provide a detailed description of the feature you are suggesting and how it would help you/others.
|
||||
validations:
|
||||
required: true
|
||||
37
.github/ISSUE_TEMPLATE/04-roadmap-contribution.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/04-roadmap-contribution.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: "🙏 Submit a Roadmap"
|
||||
description: Help us launch a new roadmap with your expertise.
|
||||
labels: [roadmap contribution]
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to submit a roadmap! Please fill out the information below and we'll get back to you as soon as we can.
|
||||
- type: input
|
||||
id: roadmap-title
|
||||
attributes:
|
||||
label: What is the title of the roadmap you are submitting?
|
||||
placeholder: e.g. Roadmap to learn Data Science
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: Is this roadmap prepared by you or someone else?
|
||||
options:
|
||||
- I prepared this roadmap
|
||||
- I found this roadmap online (please provide a link below)
|
||||
- type: textarea
|
||||
id: roadmap-description
|
||||
attributes:
|
||||
label: Roadmap Items
|
||||
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
|
||||
placeholder: |
|
||||
- Item 1
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
- Item 2
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
validations:
|
||||
required: true
|
||||
12
.github/ISSUE_TEMPLATE/05-something-else.yml
vendored
Normal file
12
.github/ISSUE_TEMPLATE/05-something-else.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: "🤷♂️ Something else"
|
||||
description: If none of the above templates fit your needs, please use this template to submit your issue.
|
||||
labels: []
|
||||
assignees: []
|
||||
body:
|
||||
- type: textarea
|
||||
id: issue-description
|
||||
attributes:
|
||||
label: Detailed Description
|
||||
description: Please provide a detailed description of the issue.
|
||||
validations:
|
||||
required: true
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ✋ Roadmap Request
|
||||
url: https://discord.gg/cJpEt5Qbwa
|
||||
about: Please do not open issues with roadmap requests, hop onto the discord server for that.
|
||||
- name: 📝 Typo or Grammatical Mistake
|
||||
url: https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data
|
||||
about: Please submit a pull request instead of reporting it as an issue.
|
||||
- name: 💬 Chat on Discord
|
||||
url: https://discord.gg/cJpEt5Qbwa
|
||||
about: Join the community on our Discord server.
|
||||
- name: 🤝 Guidance
|
||||
url: https://discord.gg/cJpEt5Qbwa
|
||||
about: Join the community in our Discord server.
|
||||
@@ -1,5 +1,4 @@
|
||||
// https://astro.build/config
|
||||
import preact from '@astrojs/preact';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
@@ -8,6 +7,8 @@ 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(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ For new roadmaps, submit a roadmap by providing [a textual roadmap similar to th
|
||||
|
||||
For the existing roadmaps, please follow the details listed for the nature of contribution:
|
||||
|
||||
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/public/jsons)
|
||||
- **Fixing Typos** — Make your changes in the [roadmap JSON file](https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/roadmaps)
|
||||
- **Adding or Removing Nodes** — Please open an issue with your suggestion.
|
||||
|
||||
**Note:** Please note that our goal is not to have the biggest list of items. Our goal is to list items or skills most relevant today.
|
||||
@@ -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
|
||||
|
||||
31
package.json
31
package.json
@@ -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",
|
||||
@@ -21,32 +21,41 @@
|
||||
"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.3",
|
||||
"astro-compress": "^1.1.47",
|
||||
"@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",
|
||||
"dracula-prism": "^2.1.13",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.9.1",
|
||||
"lucide-react": "^0.274.0",
|
||||
"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",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"tailwindcss": "^3.3.2"
|
||||
"slugify": "^1.6.6",
|
||||
"tailwindcss": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.35.0",
|
||||
"@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",
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.2.1",
|
||||
"openai": "^3.3.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-astro": "^0.10.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
|
||||
3551
pnpm-lock.yaml
generated
3551
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
8
public/images/cursors/add.svg
Normal file
8
public/images/cursors/add.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="63" height="24" viewBox="0 0 63 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="63" height="24" rx="7" fill="#563AFF"/>
|
||||
<path d="M27.2629 16.7273H25.2856L28.2984 8H30.6763L33.6848 16.7273H31.7075L29.5214 9.99432H29.4533L27.2629 16.7273ZM27.1393 13.2969H31.8098V14.7372H27.1393V13.2969Z" fill="white"/>
|
||||
<path d="M37.829 16.7273H34.7352V8H37.8545C38.7324 8 39.4881 8.17472 40.1216 8.52415C40.7551 8.87074 41.2423 9.36932 41.5832 10.0199C41.927 10.6705 42.0989 11.4489 42.0989 12.3551C42.0989 13.2642 41.927 14.0455 41.5832 14.6989C41.2423 15.3523 40.7523 15.8537 40.1131 16.2031C39.4767 16.5526 38.7153 16.7273 37.829 16.7273ZM36.5804 15.1463H37.7523C38.2977 15.1463 38.7565 15.0497 39.1287 14.8565C39.5037 14.6605 39.7849 14.358 39.9724 13.9489C40.1628 13.5369 40.2579 13.0057 40.2579 12.3551C40.2579 11.7102 40.1628 11.1832 39.9724 10.7741C39.7849 10.3651 39.5051 10.0639 39.1329 9.87074C38.7608 9.67756 38.302 9.58097 37.7565 9.58097H36.5804V15.1463Z" fill="white"/>
|
||||
<path d="M46.5594 16.7273H43.4657V8H46.585C47.4628 8 48.2185 8.17472 48.8521 8.52415C49.4856 8.87074 49.9728 9.36932 50.3137 10.0199C50.6574 10.6705 50.8293 11.4489 50.8293 12.3551C50.8293 13.2642 50.6574 14.0455 50.3137 14.6989C49.9728 15.3523 49.4827 15.8537 48.8435 16.2031C48.2072 16.5526 47.4458 16.7273 46.5594 16.7273ZM45.3109 15.1463H46.4827C47.0282 15.1463 47.487 15.0497 47.8592 14.8565C48.2342 14.6605 48.5154 14.358 48.7029 13.9489C48.8932 13.5369 48.9884 13.0057 48.9884 12.3551C48.9884 11.7102 48.8932 11.1832 48.7029 10.7741C48.5154 10.3651 48.2356 10.0639 47.8634 9.87074C47.4913 9.67756 47.0324 9.58097 46.487 9.58097H45.3109V15.1463Z" fill="white"/>
|
||||
<path d="M10 12H18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 8V16" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
5
public/images/cursors/remove.svg
Normal file
5
public/images/cursors/remove.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="89" height="24" viewBox="0 0 89 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="89" height="24" rx="7" fill="black"/>
|
||||
<path d="M23.8217 17V7.54545H27.5518C28.2659 7.54545 28.8752 7.67318 29.38 7.92862C29.8878 8.18099 30.274 8.53954 30.5387 9.00426C30.8065 9.46591 30.9403 10.0091 30.9403 10.6339C30.9403 11.2617 30.8049 11.8018 30.5341 12.2543C30.2633 12.7036 29.8709 13.0483 29.3569 13.2884C28.846 13.5284 28.2274 13.6484 27.5011 13.6484H25.0036V12.0419H27.1779C27.5595 12.0419 27.8765 11.9896 28.1289 11.8849C28.3813 11.7803 28.569 11.6233 28.6921 11.4141C28.8183 11.2048 28.8814 10.9447 28.8814 10.6339C28.8814 10.32 28.8183 10.0553 28.6921 9.83984C28.569 9.62441 28.3797 9.46129 28.1243 9.3505C27.8719 9.23662 27.5534 9.17969 27.1687 9.17969H25.8207V17H23.8217ZM28.9276 12.6974L31.2773 17H29.0707L26.7717 12.6974H28.9276ZM32.353 17V7.54545H38.7237V9.19354H34.3519V11.4464H38.396V13.0945H34.3519V15.3519H38.7422V17H32.353ZM40.3129 7.54545H42.7781L45.3818 13.8977H45.4926L48.0963 7.54545H50.5615V17H48.6226V10.8462H48.5441L46.0974 16.9538H44.7771L42.3303 10.8232H42.2519V17H40.3129V7.54545ZM60.8967 12.2727C60.8967 13.3037 60.7012 14.1809 60.3104 14.9041C59.9226 15.6274 59.3932 16.1798 58.7223 16.5614C58.0545 16.94 57.3035 17.1293 56.4695 17.1293C55.6293 17.1293 54.8752 16.9384 54.2074 16.5568C53.5395 16.1752 53.0117 15.6228 52.6239 14.8995C52.2362 14.1763 52.0423 13.3007 52.0423 12.2727C52.0423 11.2417 52.2362 10.3646 52.6239 9.64134C53.0117 8.91809 53.5395 8.36719 54.2074 7.98864C54.8752 7.60701 55.6293 7.41619 56.4695 7.41619C57.3035 7.41619 58.0545 7.60701 58.7223 7.98864C59.3932 8.36719 59.9226 8.91809 60.3104 9.64134C60.7012 10.3646 60.8967 11.2417 60.8967 12.2727ZM58.87 12.2727C58.87 11.6049 58.77 11.0417 58.57 10.5831C58.373 10.1245 58.0945 9.77675 57.7344 9.53977C57.3743 9.30279 56.9527 9.1843 56.4695 9.1843C55.9863 9.1843 55.5646 9.30279 55.2045 9.53977C54.8445 9.77675 54.5644 10.1245 54.3643 10.5831C54.1674 11.0417 54.0689 11.6049 54.0689 12.2727C54.0689 12.9406 54.1674 13.5038 54.3643 13.9624C54.5644 14.4209 54.8445 14.7687 55.2045 15.0057C55.5646 15.2427 55.9863 15.3612 56.4695 15.3612C56.9527 15.3612 57.3743 15.2427 57.7344 15.0057C58.0945 14.7687 58.373 14.4209 58.57 13.9624C58.77 13.5038 58.87 12.9406 58.87 12.2727ZM63.5523 7.54545L65.8374 14.7287H65.9252L68.2149 7.54545H70.4308L67.1716 17H64.5956L61.3318 7.54545H63.5523ZM71.5688 17V7.54545H77.9395V9.19354H73.5677V11.4464H77.6118V13.0945H73.5677V15.3519H77.958V17H71.5688Z" fill="white"/>
|
||||
<path d="M8 12L17 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/og-images/sql-roadmap.png
Normal file
BIN
public/og-images/sql-roadmap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
public/pdfs/roadmaps/ai-data-scientist.pdf
Normal file
BIN
public/pdfs/roadmaps/ai-data-scientist.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/pdfs/roadmaps/react-native.pdf
Normal file
BIN
public/pdfs/roadmaps/react-native.pdf
Normal file
Binary file not shown.
BIN
public/pdfs/roadmaps/sql.pdf
Normal file
BIN
public/pdfs/roadmaps/sql.pdf
Normal file
Binary file not shown.
BIN
public/roadmaps/sql.png
Normal file
BIN
public/roadmaps/sql.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 550 KiB |
13
readme.md
13
readme.md
@@ -35,25 +35,28 @@ 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)
|
||||
- [TypeScript Roadmap](https://roadmap.sh/typescript)
|
||||
- [C++ Roadmap](https://roadmap.sh/cpp)
|
||||
- [React Roadmap](https://roadmap.sh/react)
|
||||
- [React Native Roadmap](https://roadmap.sh/react-native)
|
||||
- [Vue Roadmap](https://roadmap.sh/vue)
|
||||
- [Angular Roadmap](https://roadmap.sh/angular)
|
||||
- [Node.js Roadmap](https://roadmap.sh/nodejs)
|
||||
- [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)
|
||||
- [Design System Roadmap](https://roadmap.sh/design-system)
|
||||
- [DBA Roadmap](https://roadmap.sh/postgresql-dba)
|
||||
- [PostgreSQL Roadmap](https://roadmap.sh/postgresql-dba)
|
||||
- [SQL Roadmap](https://roadmap.sh/sql)
|
||||
- [Blockchain Roadmap](https://roadmap.sh/blockchain)
|
||||
- [ASP.NET Core Roadmap](https://roadmap.sh/aspnet-core)
|
||||
- [System Design Roadmap](https://roadmap.sh/system-design)
|
||||
@@ -93,6 +96,12 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Note: use the `depth` parameter to reduce the clone size and speed up the clone.
|
||||
|
||||
```sh
|
||||
git clone --depth=1 https://github.com/kamranahmedse/developer-roadmap.git
|
||||
```
|
||||
|
||||
## Contribution
|
||||
|
||||
> Have a look at [contribution docs](./contributing.md) for how to update any of the roadmaps
|
||||
|
||||
@@ -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(':');
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
|
||||
const { activePageId, activePageTitle } = Astro.props;
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
activePageTitle: string;
|
||||
hasDesktopSidebar?: boolean;
|
||||
}
|
||||
|
||||
const { hasDesktopSidebar = true, activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
href: '/account',
|
||||
@@ -19,11 +22,21 @@ const sidebarLinks = [
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/friends',
|
||||
title: 'Friends',
|
||||
id: 'friends',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'users',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
id: 'road-card',
|
||||
isNew: true,
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'badge',
|
||||
classes: 'h-4 w-4',
|
||||
@@ -40,12 +53,12 @@ const sidebarLinks = [
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-password',
|
||||
title: 'Security',
|
||||
id: 'change-password',
|
||||
href: '/account/settings',
|
||||
title: 'Settings',
|
||||
id: 'settings',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'security',
|
||||
glyph: 'cog',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
@@ -64,6 +77,17 @@ const sidebarLinks = [
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href='/team'
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
activePageId === 'team' ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
@@ -91,48 +115,63 @@ const sidebarLinks = [
|
||||
|
||||
<div class='container flex min-h-screen items-stretch'>
|
||||
<!-- Start Desktop Sidebar -->
|
||||
<aside class='hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
{
|
||||
hasDesktopSidebar && (
|
||||
<aside class='hidden w-[195px] shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<TeamDropdown client:load />
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{sidebarLink.isNew &&
|
||||
sidebarLink.id !== 'friends' &&
|
||||
!isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{sidebarLink.id === 'friends' && (
|
||||
<SidebarFriendsCounter client:load />
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div class='grow px-0 py-0 md:px-10 md:py-10'>
|
||||
<div
|
||||
class:list={[
|
||||
'grow px-0 py-0 md:py-10',
|
||||
{ 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar },
|
||||
]}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 ActivityResponse = {
|
||||
export type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
@@ -91,16 +91,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 +110,7 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
key={roadmap.id}
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@@ -11,10 +13,13 @@ type ResourceProgressType = {
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
onCleared: () => void;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
@@ -41,17 +46,20 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
alert('Error clearing progress. Please try again.');
|
||||
toast.error('Error clearing progress. Please try again.');
|
||||
console.error(error);
|
||||
setIsClearing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
console.log(`${resourceType}-${resourceId}-progress`);
|
||||
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
onCleared();
|
||||
if (onCleared) {
|
||||
onCleared();
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
@@ -81,7 +89,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
{getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</a>
|
||||
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<div className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<span className="hidden flex-1 gap-1 sm:flex">
|
||||
{doneCount > 0 && (
|
||||
<>
|
||||
@@ -100,40 +108,55 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-start">
|
||||
<ProgressShareButton
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
className="text-xs font-normal"
|
||||
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
||||
checkIconClassName="w-2.5 h-2.5"
|
||||
/>
|
||||
<span className={'hidden sm:block'}>•</span>
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
174
src/components/AddTeamRoadmap.tsx
Normal file
174
src/components/AddTeamRoadmap.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { OptionType, SearchSelector } from './SearchSelector';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { httpPut } from '../lib/http';
|
||||
import type { TeamResourceConfig } from './CreateTeam/RoadmapSelector';
|
||||
import { Spinner } from './ReactIcons/Spinner';
|
||||
|
||||
type AddTeamRoadmapProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
availableRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
onMakeChanges: (roadmapId: string) => void;
|
||||
setResourceConfigs: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
const {
|
||||
teamId,
|
||||
onMakeChanges,
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
availableRoadmaps,
|
||||
setResourceConfigs,
|
||||
} = props;
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedRoadmap, setSelectedRoadmap] = useState<string>('');
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setResourceConfigs(response);
|
||||
}
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
const selectedRoadmapTitle = allRoadmaps.find(
|
||||
(roadmap) => roadmap.id === selectedRoadmap
|
||||
)?.title;
|
||||
|
||||
return (
|
||||
<div 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}
|
||||
className="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && selectedRoadmap && (
|
||||
<div className={'text-center'}>
|
||||
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
|
||||
<h3 className="mb-1.5 text-2xl font-medium">
|
||||
{selectedRoadmapTitle} Added
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-none text-gray-400">
|
||||
<button
|
||||
onClick={() => onMakeChanges(selectedRoadmap)}
|
||||
className="underline underline-offset-2 hover:text-gray-900"
|
||||
>
|
||||
Click here
|
||||
</button>{' '}
|
||||
to make changes to the roadmap.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRoadmap('');
|
||||
setError('');
|
||||
setIsLoading(false);
|
||||
}}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
>
|
||||
+ Add More
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<>
|
||||
<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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && !selectedRoadmap && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<SearchSelector
|
||||
options={availableRoadmaps.map((roadmap) => ({
|
||||
value: roadmap.id,
|
||||
label: roadmap.title,
|
||||
}))}
|
||||
onSelect={(option: OptionType) => {
|
||||
const roadmapId = option.value;
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
setIsLoading(false);
|
||||
setSelectedRoadmap(roadmapId);
|
||||
});
|
||||
}}
|
||||
inputClassName="mt-2 mb-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Search for roadmap'}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,8 +90,14 @@ 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;
|
||||
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
@@ -100,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>
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
@@ -85,8 +86,13 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
['/respond-invite', '/befriend'].includes(window.location.pathname)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
@@ -100,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>
|
||||
|
||||
126
src/components/AuthenticationFlow/LinkedInButton.tsx
Normal file
126
src/components/AuthenticationFlow/LinkedInButton.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type LinkedInButtonProps = {};
|
||||
|
||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||
|
||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : LinkedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'linkedin') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
|
||||
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (linkedInRedirectAt && lastPageBeforeLinkedIn) {
|
||||
const socialRedirectAtTime = parseInt(linkedInRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeLinkedIn;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath = ['/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);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt="Google"
|
||||
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
---
|
||||
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';
|
||||
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>
|
||||
@@ -19,6 +20,7 @@ import { GoogleButton } from './GoogleButton';
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
<LinkedInButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -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 = '/';
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,9 +33,18 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
function handleGuest() {
|
||||
const authenticatedRoutes = [
|
||||
'/account/update-profile',
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/road-card',
|
||||
'/account/friends',
|
||||
'/account',
|
||||
'/team',
|
||||
'/team/progress',
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/settings',
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
|
||||
368
src/components/Befriend.tsx
Normal file
368
src/components/Befriend.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpDelete, httpGet, httpPost } from '../lib/http';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { isLoggedIn } from '../lib/jwt';
|
||||
import { showLoginPopup } from '../lib/popup';
|
||||
import { getUrlParams } from '../lib/browser';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { DeleteUserIcon } from './ReactIcons/DeleteUserIcon';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
import { useAuth } from '../hooks/use-auth';
|
||||
import { AddedUserIcon } from './ReactIcons/AddedUserIcon';
|
||||
import { StopIcon } from './ReactIcons/StopIcon';
|
||||
import { ErrorIcon } from './ReactIcons/ErrorIcon';
|
||||
|
||||
export type FriendshipStatus =
|
||||
| 'none'
|
||||
| 'sent'
|
||||
| 'received'
|
||||
| 'accepted'
|
||||
| 'rejected'
|
||||
| 'got_rejected';
|
||||
|
||||
type UserResponse = {
|
||||
id: string;
|
||||
links: Record<string, string>;
|
||||
avatar: string;
|
||||
name: string;
|
||||
status: FriendshipStatus;
|
||||
};
|
||||
|
||||
export function Befriend() {
|
||||
const { u: inviteId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const currentUser = useAuth();
|
||||
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [user, setUser] = useState<UserResponse>();
|
||||
const isAuthenticated = isLoggedIn();
|
||||
|
||||
async function loadUser(userId: string) {
|
||||
const { response, error } = await httpGet<UserResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-friend/${userId}`
|
||||
);
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'accepted') {
|
||||
window.location.href = '/account/friends?c=fa';
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (inviteId) {
|
||||
loadUser(inviteId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setError('Missing invite ID in URL');
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
}, [inviteId]);
|
||||
|
||||
async function addFriend(userId: string, successMessage: string) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
setError('');
|
||||
const { response, error } = await httpPost<UserResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'accepted') {
|
||||
window.location.href = '/account/friends?c=fa';
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(response);
|
||||
}
|
||||
|
||||
async function deleteFriend(userId: string, successMessage: string) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
setError('');
|
||||
const { response, error } = await httpDelete<UserResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(response);
|
||||
toast.success(successMessage);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="container text-center">
|
||||
<ErrorIcon additionalClasses="mx-auto mb-4 mt-24 w-20 opacity-20" />
|
||||
|
||||
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
|
||||
<p className="mb-4 text-base leading-6 text-gray-600">
|
||||
{error || 'There was a problem, please try again.'}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
|
||||
>
|
||||
Back to home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const userAvatar = user.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
const isMe = currentUser?.id === user.id;
|
||||
|
||||
return (
|
||||
<div className="container !max-w-[400px] text-center">
|
||||
<img
|
||||
alt={'join team'}
|
||||
src={userAvatar}
|
||||
className="mx-auto mb-4 mt-24 w-28 rounded-full"
|
||||
/>
|
||||
|
||||
<h2 className={'mb-1 text-3xl font-bold'}>{user.name}</h2>
|
||||
<p className="mb-6 text-base leading-6 text-gray-600">
|
||||
After you add {user.name} as a friend, you will be able to view each
|
||||
other's skills and progress.
|
||||
</p>
|
||||
|
||||
<div className="mx-auto w-full duration-500 sm:max-w-md">
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
{user.status === 'none' && (
|
||||
<button
|
||||
disabled={isMe}
|
||||
onClick={() => {
|
||||
if (!isAuthenticated) {
|
||||
return showLoginPopup();
|
||||
}
|
||||
|
||||
addFriend(user.id, 'Friend request sent').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
className="w-full flex-grow cursor-pointer rounded-lg bg-black px-3 py-2 text-center text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{isMe ? "You can't add yourself" : 'Add Friend'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.status === 'sent' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
|
||||
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Request Sent
|
||||
</span>
|
||||
|
||||
{!isConfirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
type="button"
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
|
||||
>
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
|
||||
Withdraw Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
deleteFriend(user.id, 'Friend request withdrawn').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
className="ml-2 text-red-600 underline"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'accepted' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
|
||||
<AddedUserIcon additionalClasses="mr-2 h-5 w-5" />
|
||||
You are friends
|
||||
</span>
|
||||
|
||||
{!isConfirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
type="button"
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
|
||||
>
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
|
||||
Remove Friend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
deleteFriend(user.id, 'Friend removed').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
className="ml-2 text-red-600 underline"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'rejected' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Request Rejected
|
||||
</span>
|
||||
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Changed your mind?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
addFriend(user.id, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Click here to Accept
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'got_rejected' && (
|
||||
<>
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-500 px-3 py-2 text-center text-red-500">
|
||||
<StopIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Request Rejected
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.status === 'received' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
addFriend(user.id, 'Friend request accepted').finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-gray-800 bg-gray-800 px-3 py-2 text-center text-white hover:bg-black"
|
||||
>
|
||||
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
|
||||
Accept Request
|
||||
</button>
|
||||
|
||||
{!isConfirming && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(true);
|
||||
}}
|
||||
type="button"
|
||||
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-white px-3 py-2 text-center text-red-600 hover:bg-red-100"
|
||||
>
|
||||
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
|
||||
Reject Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 text-red-700 underline"
|
||||
onClick={() => {
|
||||
deleteFriend(user.id, 'Friend request rejected').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsConfirming(false);
|
||||
}}
|
||||
className="ml-2 text-red-600 underline"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import BestPracticeHint from './BestPracticeHint.astro';
|
||||
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
|
||||
import ProgressHelpPopup from './ProgressHelpPopup.astro';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -15,36 +17,43 @@ const isBestPracticeReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<LoginPopup />
|
||||
<ProgressHelpPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<div class='mb-3 mt-0 sm:mb-6'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
<div class="border-b">
|
||||
<div class="container relative py-5 sm:py-12">
|
||||
<div class="mb-3 mt-0 sm:mb-6">
|
||||
<h1 class="mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl">
|
||||
{title}
|
||||
<MarkFavorite
|
||||
resourceId={bestPracticeId}
|
||||
resourceType="best-practice"
|
||||
className="text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0"
|
||||
client:load
|
||||
/>
|
||||
</h1>
|
||||
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
|
||||
<p class="text-sm text-gray-500 sm:text-lg">{description}</p>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between'>
|
||||
<div class='flex gap-1 sm:gap-2'>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex gap-1 sm:gap-2">
|
||||
<a
|
||||
href='/best-practices'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to All Best Practices'
|
||||
href="/best-practices"
|
||||
class="rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Back to All Best Practices"
|
||||
>
|
||||
←<span class='hidden sm:inline'> All Best Practices</span>
|
||||
←<span class="hidden sm:inline"> All Best Practices</span>
|
||||
</a>
|
||||
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='hidden inline-flex items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
data-popup="login-popup"
|
||||
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Download Roadmap"
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
<Icon icon="download" />
|
||||
<span class="ml-2 hidden sm:inline">Download</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -53,25 +62,25 @@ const isBestPracticeReady = !isUpcoming;
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
data-auth-required
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Download Roadmap"
|
||||
target="_blank"
|
||||
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
<Icon icon="download" />
|
||||
<span class="ml-2 hidden sm:inline">Download</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Subscribe for Updates'
|
||||
data-popup="login-popup"
|
||||
class="inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm"
|
||||
aria-label="Subscribe for Updates"
|
||||
>
|
||||
<Icon icon='email' />
|
||||
<span class='ml-2'>Subscribe</span>
|
||||
<Icon icon="email" />
|
||||
<span class="ml-2">Subscribe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -79,13 +88,13 @@ const isBestPracticeReady = !isUpcoming;
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Suggest Changes'
|
||||
target="_blank"
|
||||
class="inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm"
|
||||
aria-label="Suggest Changes"
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
<span class='ml-2 hidden sm:inline'>Suggest Changes</span>
|
||||
<span class='ml-2 inline sm:hidden'>Suggest</span>
|
||||
<Icon icon="comment" class="h-3 w-3" />
|
||||
<span class="ml-2 hidden sm:inline">Suggest Changes</span>
|
||||
<span class="ml-2 inline sm:hidden">Suggest</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
export interface Props {
|
||||
bestPracticeId: string;
|
||||
}
|
||||
const { bestPracticeId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
|
||||
<ResourceProgressStats />
|
||||
<ResourceProgressStats resourceId={bestPracticeId} resourceType='best-practice' />
|
||||
</div>
|
||||
|
||||
@@ -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> · </span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <span class='text-gray-400'>{breadcrumb.title}</span>;
|
||||
})
|
||||
}
|
||||
</p>
|
||||
|
||||
<!-- Mobile breadcrums -->
|
||||
<p class='container block sm:hidden'>
|
||||
<a
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
href={`/${roadmapId}`}
|
||||
>
|
||||
← Back to Topics List
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,41 +1,80 @@
|
||||
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';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import GroupIcon from '../../icons/group.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
type PageType = {
|
||||
export type PageType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon.src },
|
||||
{
|
||||
id: 'account',
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon,
|
||||
icon: UserIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
|
||||
{
|
||||
id: 'team',
|
||||
url: '/team',
|
||||
title: 'Teams',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'roadmaps',
|
||||
url: '/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
group: 'Pages',
|
||||
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.src,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
url: '/videos',
|
||||
title: 'Videos',
|
||||
group: 'Pages',
|
||||
icon: VideoIcon.src,
|
||||
},
|
||||
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
|
||||
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
|
||||
];
|
||||
|
||||
function shouldShowPage(page: PageType) {
|
||||
@@ -127,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);
|
||||
@@ -160,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 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>
|
||||
|
||||
69
src/components/Confetti.tsx
Normal file
69
src/components/Confetti.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
216
src/components/CreateTeam/CreateTeamForm.tsx
Normal file
216
src/components/CreateTeam/CreateTeamForm.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Stepper } from '../Stepper';
|
||||
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';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { Step3 } from './Step3';
|
||||
import { Step4 } from './Step4';
|
||||
import {useToast} from "../../hooks/use-toast";
|
||||
|
||||
export interface TeamDocument {
|
||||
_id?: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
creatorId: string;
|
||||
links: {
|
||||
website?: string;
|
||||
github?: string;
|
||||
linkedIn?: string;
|
||||
};
|
||||
type: ValidTeamType;
|
||||
canMemberSendInvite: boolean;
|
||||
teamSize?: ValidTeamSize;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export function CreateTeamForm() {
|
||||
// Can't use hook `useParams` because it runs asynchronously
|
||||
const { s: queryStepIndex, t: teamId } = getUrlParams();
|
||||
|
||||
const toast = useToast();
|
||||
const [team, setTeam] = useState<TeamDocument>();
|
||||
|
||||
const [loadingTeam, setLoadingTeam] = useState(!!teamId && !team?._id);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
|
||||
async function loadTeam(
|
||||
teamIdToFetch: string,
|
||||
requiredStepIndex: number | string
|
||||
) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error loading team');
|
||||
window.location.href = '/account';
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredStepIndexNumber = parseInt(requiredStepIndex as string, 10);
|
||||
const completedSteps = Array(requiredStepIndexNumber)
|
||||
.fill(1)
|
||||
.map((_, counter) => counter);
|
||||
|
||||
setTeam(response);
|
||||
setSelectedTeamType(response.type);
|
||||
setCompletedSteps(completedSteps);
|
||||
setStepIndex(requiredStepIndexNumber);
|
||||
|
||||
await loadTeamResourceConfig(teamIdToFetch);
|
||||
}
|
||||
|
||||
const [teamResourceConfig, setTeamResourceConfig] =
|
||||
useState<TeamResourceConfig>([]);
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!teamId || !queryStepIndex || team) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Fetching team');
|
||||
setLoadingTeam(true);
|
||||
loadTeam(teamId, queryStepIndex).finally(() => {
|
||||
setLoadingTeam(false);
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
// fetch team and move to step
|
||||
}, [teamId, queryStepIndex]);
|
||||
|
||||
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
|
||||
team?.type || 'company'
|
||||
);
|
||||
|
||||
const [completedSteps, setCompletedSteps] = useState([0]);
|
||||
if (loadingTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stepForm = null;
|
||||
if (stepIndex === 0) {
|
||||
stepForm = (
|
||||
<Step0
|
||||
team={team}
|
||||
selectedTeamType={selectedTeamType}
|
||||
setSelectedTeamType={setSelectedTeamType}
|
||||
onStepComplete={() => {
|
||||
if (team?._id) {
|
||||
setUrlParams({ t: team._id, s: '1' });
|
||||
}
|
||||
|
||||
setCompletedSteps([0]);
|
||||
setStepIndex(1);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 1) {
|
||||
stepForm = (
|
||||
<Step1
|
||||
team={team}
|
||||
onBack={() => {
|
||||
if (team?._id) {
|
||||
setUrlParams({ t: team._id, s: '0' });
|
||||
}
|
||||
|
||||
setStepIndex(0);
|
||||
}}
|
||||
onStepComplete={(team: TeamDocument) => {
|
||||
const createdTeamId = team._id!;
|
||||
|
||||
setUrlParams({ t: createdTeamId, s: '2' });
|
||||
|
||||
setCompletedSteps([0, 1]);
|
||||
setStepIndex(2);
|
||||
setTeam(team);
|
||||
}}
|
||||
selectedTeamType={selectedTeamType}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 2) {
|
||||
stepForm = (
|
||||
<Step2
|
||||
team={team!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
onBack={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '1' });
|
||||
}
|
||||
|
||||
setStepIndex(1);
|
||||
}}
|
||||
onNext={() => {
|
||||
setUrlParams({ t: teamId!, s: '3' });
|
||||
setCompletedSteps([0, 1, 2]);
|
||||
setStepIndex(3);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 3) {
|
||||
stepForm = (
|
||||
<Step3
|
||||
team={team}
|
||||
onBack={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '2' });
|
||||
}
|
||||
|
||||
setStepIndex(2);
|
||||
}}
|
||||
onNext={() => {
|
||||
if (team) {
|
||||
setUrlParams({ t: team._id!, s: '4' });
|
||||
}
|
||||
|
||||
setCompletedSteps([0, 1, 2, 3]);
|
||||
setStepIndex(4);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (stepIndex === 4) {
|
||||
stepForm = <Step4 team={team!} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'mx-auto max-w-[700px] py-1 md:py-6'}>
|
||||
<div className={'mb-3 md:mb-8 pb-3 md:pb-0 border-b md:border-b-0 flex flex-col items-start md:items-center'}>
|
||||
<h1 className={'text-xl md:text-4xl font-bold'}>Create Team</h1>
|
||||
<p className={'mt-1 md:mt-2 text-sm md:text-base text-gray-500'}>
|
||||
Complete the steps below to create your team
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-8 mt-8 hidden sm:flex w-full">
|
||||
<Stepper
|
||||
activeIndex={stepIndex}
|
||||
completeSteps={completedSteps}
|
||||
steps={[
|
||||
{ label: 'Type' },
|
||||
{ label: 'Details' },
|
||||
{ label: 'Skills' },
|
||||
{ label: 'Members' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{stepForm}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/CreateTeam/NextButton.tsx
Normal file
44
src/components/CreateTeam/NextButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type NextButtonProps = {
|
||||
isLoading?: boolean;
|
||||
loadingMessage?: string;
|
||||
text: string;
|
||||
hasNextArrow?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function NextButton(props: NextButtonProps) {
|
||||
const {
|
||||
isLoading = false,
|
||||
text = 'Next Step',
|
||||
type = 'button',
|
||||
loadingMessage = 'Please wait ..',
|
||||
onClick = () => null,
|
||||
hasNextArrow = true,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type as any}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
'rounded-md border border-black bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={'flex items-center justify-center'}>
|
||||
<Spinner />
|
||||
<span className="ml-2">{loadingMessage}</span>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{text}
|
||||
{hasNextArrow && <span className="ml-1">→</span>}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
47
src/components/CreateTeam/NotDropdown.tsx
Normal file
47
src/components/CreateTeam/NotDropdown.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
|
||||
type NotDropdownProps = {
|
||||
onClick: () => void;
|
||||
selectedCount: number;
|
||||
singularName: string;
|
||||
pluralName: string;
|
||||
};
|
||||
|
||||
export function NotDropdown(props: NotDropdownProps) {
|
||||
const { onClick, selectedCount, singularName, pluralName } = props;
|
||||
|
||||
const singularOrPlural = selectedCount === 1 ? singularName : pluralName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-text items-center justify-between rounded-md border border-gray-300 px-3 py-2.5 hover:border-gray-400/50 hover:bg-gray-50"
|
||||
role="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="mb-1.5 text-base font-medium text-gray-800">
|
||||
{selectedCount} {singularOrPlural} selected
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
Click to add or change selection
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCount === 0 && (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-base text-gray-400">
|
||||
Click to select {pluralName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={singularName}
|
||||
src={ChevronDownIcon.src}
|
||||
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
src/components/CreateTeam/RoadmapSelector.tsx
Normal file
218
src/components/CreateTeam/RoadmapSelector.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
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';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
removed: string[];
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
teamId: string;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
|
||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Something went wrong. Please try again!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allRoadmaps = response
|
||||
.filter((page) => page.group === 'Roadmaps')
|
||||
.sort((a, b) => {
|
||||
if (a.title === 'Android') return 1;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
setAllRoadmaps(allRoadmaps);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function deleteResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Deleting resource`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-delete-team-resource-config/${teamId}`,
|
||||
{
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error deleting roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
pageProgressMessage.set('Removing roadmap');
|
||||
|
||||
deleteResource(resourceId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
async function addTeamResource(roadmapId: string) {
|
||||
if (!teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set(`Adding roadmap to team`);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: roadmapId,
|
||||
resourceType: 'roadmap',
|
||||
removed: [],
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAllRoadmaps().finally();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changingRoadmapId && (
|
||||
<UpdateTeamResourceModal
|
||||
onClose={() => setChangingRoadmapId('')}
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
onRoadmapRemove={(roadmapId) => {
|
||||
onRemove(roadmapId).finally(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<NotDropdown
|
||||
onClick={() => {
|
||||
setShowSelectRoadmapModal(true);
|
||||
}}
|
||||
selectedCount={teamResourceConfig.length}
|
||||
singularName={'roadmap'}
|
||||
pluralName={'roadmaps'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
||||
No roadmaps selected.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-none text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => setChangingRoadmapId(resourceId)}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => onRemove(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/components/CreateTeam/RoleDropdown.tsx
Normal file
132
src/components/CreateTeam/RoleDropdown.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
const allowedRoles = [
|
||||
{
|
||||
name: 'Admin',
|
||||
value: 'admin',
|
||||
description: 'Can do everything',
|
||||
},
|
||||
{
|
||||
name: 'Manager',
|
||||
value: 'manager',
|
||||
description: 'Can manage team and skills',
|
||||
},
|
||||
{
|
||||
name: 'Member',
|
||||
value: 'member',
|
||||
description: 'Can view team and skills',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type AllowedRoles = (typeof allowedRoles)[number]['value'];
|
||||
|
||||
type RoleDropdownProps = {
|
||||
className?: string;
|
||||
selectedRole: string;
|
||||
setSelectedRole: (role: AllowedRoles) => void;
|
||||
};
|
||||
|
||||
export function RoleDropdown(props: RoleDropdownProps) {
|
||||
const { selectedRole, setSelectedRole, className = 'w-[120px]' } = props;
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const [activeRoleIndex, setActiveRoleIndex] = useState(0);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useOutsideClick(dropdownRef, () => {
|
||||
setIsMenuOpen(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
type={'button'}
|
||||
onKeyDown={(e) => {
|
||||
const isUpOrDown = e.key === 'ArrowUp' || e.key === 'ArrowDown';
|
||||
if (isUpOrDown && !isMenuOpen) {
|
||||
e.preventDefault();
|
||||
setIsMenuOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (isEnter && isMenuOpen) {
|
||||
e.preventDefault();
|
||||
setSelectedRole(allowedRoles[activeRoleIndex].value);
|
||||
setIsMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveRoleIndex((prev) => {
|
||||
const nextIndex = prev + 1;
|
||||
if (nextIndex >= allowedRoles.length) {
|
||||
return 0;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveRoleIndex((prev) => {
|
||||
const nextIndex = prev - 1;
|
||||
if (nextIndex < 0) {
|
||||
return allowedRoles.length - 1;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={`flex h-full w-full cursor-default items-center justify-between rounded-md border px-4 ${
|
||||
isMenuOpen ? 'border-gray-300 bg-gray-100' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`capitalize`}>
|
||||
{selectedRole || 'Select Role'}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={'relative top-0.5 ml-2 h-4 w-4 text-gray-400'}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
className="absolute z-10 mt-1 w-[200px] rounded-md border bg-white shadow-md"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
<div
|
||||
className="py-1"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
{allowedRoles.map((allowedRole, roleCounter) => (
|
||||
<button
|
||||
key={allowedRole.value}
|
||||
type={'button'}
|
||||
className={`w-full cursor-default px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 ${
|
||||
roleCounter === activeRoleIndex ? 'bg-gray-100' : 'bg-white'
|
||||
}`}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
setSelectedRole(allowedRole.value);
|
||||
}}
|
||||
>
|
||||
<span className="block font-medium">{allowedRole.name}</span>
|
||||
<span className="block text-xs text-gray-400">
|
||||
{allowedRole.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/components/CreateTeam/SelectRoadmapModal.tsx
Normal file
152
src/components/CreateTeam/SelectRoadmapModal.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
|
||||
|
||||
export type SelectRoadmapModalProps = {
|
||||
teamId: string;
|
||||
allRoadmaps: PageType[];
|
||||
onClose: () => void;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
onRoadmapAdd: (roadmapId: string) => void;
|
||||
onRoadmapRemove: (roadmapId: string) => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
const {
|
||||
onClose,
|
||||
allRoadmaps,
|
||||
onRoadmapAdd,
|
||||
onRoadmapRemove,
|
||||
teamResourceConfig,
|
||||
} = props;
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const searchInputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(allRoadmaps);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchInputEl.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchInputEl.current.focus();
|
||||
}, [searchInputEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchText.length === 0) {
|
||||
setSearchResults(allRoadmaps);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = allRoadmaps.filter((roadmap) => {
|
||||
return (
|
||||
roadmap.title.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
roadmap.id.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
});
|
||||
setSearchResults(searchResults);
|
||||
}, [searchText, allRoadmaps]);
|
||||
|
||||
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('role-roadmap')
|
||||
);
|
||||
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('skill-roadmap')
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
|
||||
<span className="sr-only">Close modal</span>
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputEl}
|
||||
type="text"
|
||||
placeholder="Search roadmaps"
|
||||
className="block w-full border-b px-5 pb-3.5 pt-4 outline-none placeholder:text-gray-400"
|
||||
value={searchText}
|
||||
onInput={(e) => setSearchText((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="min-h-[200px] p-4">
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Role Based Roadmaps
|
||||
</span>
|
||||
{roleBasedRoadmaps.length === 0 && (
|
||||
<p className="mb-1 flex h-full items-start text-sm italic text-gray-400"></p>
|
||||
)}
|
||||
{roleBasedRoadmaps.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<span className="block pb-3 text-xs uppercase text-gray-400">
|
||||
Skill Based Roadmaps
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{skillBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onRoadmapRemove(roadmap.id);
|
||||
} else {
|
||||
onRoadmapAdd(roadmap.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/CreateTeam/SelectRoadmapModalItem.tsx
Normal file
34
src/components/CreateTeam/SelectRoadmapModalItem.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { SelectRoadmapModalProps } from './SelectRoadmapModal';
|
||||
|
||||
type SelectRoadmapModalItemProps = {
|
||||
title: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function SelectRoadmapModalItem(props: SelectRoadmapModalItemProps) {
|
||||
const { isSelected, onClick, title } = props;
|
||||
return (
|
||||
<button
|
||||
className={`group flex min-h-[35px] items-stretch overflow-hidden rounded-md text-sm ${
|
||||
!isSelected
|
||||
? 'border border-gray-300 hover:bg-gray-100'
|
||||
: 'bg-black text-white transition-colors hover:bg-gray-700'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="flex items-center px-3">{title}</span>
|
||||
{isSelected && (
|
||||
<span className="flex items-center bg-gray-700 px-3 text-xs text-white transition-colors">
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isSelected && (
|
||||
<span className="flex items-center bg-gray-100 px-2.5 text-xs text-gray-500">
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
122
src/components/CreateTeam/Step0.tsx
Normal file
122
src/components/CreateTeam/Step0.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import BuildingIcon from '../../icons/building.svg';
|
||||
import UsersIcon from '../../icons/users.svg';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { useState } from 'react';
|
||||
import { NextButton } from './NextButton';
|
||||
|
||||
export const validTeamTypes = [
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon.src,
|
||||
description: 'Track the skills and learning progress of the tech team at your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon.src,
|
||||
description: 'Invite your friends or course-mates and track your learning progress together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ValidTeamType = (typeof validTeamTypes)[number]['value'];
|
||||
|
||||
type Step0Props = {
|
||||
team?: TeamDocument;
|
||||
selectedTeamType: ValidTeamType;
|
||||
setSelectedTeamType: (teamType: ValidTeamType) => void;
|
||||
onStepComplete: () => void;
|
||||
};
|
||||
|
||||
export function Step0(props: Step0Props) {
|
||||
const { team, selectedTeamType, onStepComplete, setSelectedTeamType } = props;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
async function onNextClick() {
|
||||
if (!team) {
|
||||
onStepComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
|
||||
{
|
||||
name: team.name,
|
||||
website: team?.links?.website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: team?.links?.github || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize: team.teamSize,
|
||||
linkedInUrl: team?.links?.linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
onStepComplete();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col sm:flex-row gap-3'}>
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
||||
validTeamType.value == selectedTeamType
|
||||
? 'border-gray-400 bg-gray-100'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => setSelectedTeamType(validTeamType.value)}
|
||||
>
|
||||
<img
|
||||
alt={validTeamType.label}
|
||||
src={validTeamType.icon}
|
||||
className={`mb-3 h-12 w-12 opacity-10 ${
|
||||
validTeamType.value === selectedTeamType ? 'opacity-100' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="mb-2 block text-2xl font-bold">
|
||||
{validTeamType.label}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 leading-[21px]">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*Error message*/}
|
||||
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<a
|
||||
href="/account"
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500 text-center'
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<NextButton
|
||||
type={'button'}
|
||||
onClick={onNextClick}
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
loadingMessage={'Updating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
src/components/CreateTeam/Step1.tsx
Normal file
260
src/components/CreateTeam/Step1.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
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';
|
||||
|
||||
export const validTeamSizes = [
|
||||
'1-5',
|
||||
'6-10',
|
||||
'11-25',
|
||||
'26-50',
|
||||
'51-100',
|
||||
'101-200',
|
||||
'201-500',
|
||||
'501-1000',
|
||||
'1000+',
|
||||
] as const;
|
||||
|
||||
export type ValidTeamSize = (typeof validTeamSizes)[number];
|
||||
|
||||
type Step1Props = {
|
||||
team?: TeamDocument;
|
||||
selectedTeamType: ValidTeamType;
|
||||
onStepComplete: (team: TeamDocument) => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function Step1(props: Step1Props) {
|
||||
const { team, selectedTeamType, onBack, onStepComplete } = props;
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const nameRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
nameRef.current.focus();
|
||||
}, [nameRef]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [name, setName] = useState(team?.name || '');
|
||||
const [website, setWebsite] = useState(team?.links?.website || '');
|
||||
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
|
||||
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
|
||||
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
|
||||
team?.teamSize || ('' as any)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !selectedTeamType) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let response: TeamDocument | undefined;
|
||||
let error: AppError | undefined;
|
||||
|
||||
if (!team?._id) {
|
||||
({ response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-create-team`,
|
||||
{
|
||||
name,
|
||||
website: website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: gitHubUrl || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
roadmapIds: [],
|
||||
bestPracticeIds: [],
|
||||
}
|
||||
));
|
||||
|
||||
if (error || !response?._id) {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onStepComplete(response as TeamDocument);
|
||||
} else {
|
||||
({ response, error } = await httpPut(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-team/${team._id}`,
|
||||
{
|
||||
name,
|
||||
website: website || undefined,
|
||||
type: selectedTeamType,
|
||||
gitHubUrl: gitHubUrl || undefined,
|
||||
...(selectedTeamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
}
|
||||
));
|
||||
|
||||
if (error || (response as any)?.status !== 'ok') {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onStepComplete({
|
||||
...team,
|
||||
name,
|
||||
_id: team._id,
|
||||
links: {
|
||||
website: website || team?.links?.website,
|
||||
linkedIn: linkedInUrl || team?.links?.linkedIn,
|
||||
github: gitHubUrl || team?.links?.github,
|
||||
},
|
||||
type: selectedTeamType,
|
||||
teamSize: teamSize!,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
ref={nameRef as any}
|
||||
autoFocus={true}
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Roadmap Inc."
|
||||
disabled={isLoading}
|
||||
required
|
||||
value={name}
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
required
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://roadmap.sh"
|
||||
disabled={isLoading}
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
Company LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://www.linkedin.com/company/roadmapsh"
|
||||
disabled={isLoading}
|
||||
value={linkedInUrl}
|
||||
onInput={(e) =>
|
||||
setLinkedInUrl((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
GitHub Organization URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/roadmapsh"
|
||||
disabled={isLoading}
|
||||
value={gitHubUrl}
|
||||
onInput={(e) => setGitHubUrl((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
htmlFor="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Tech Team Size
|
||||
</label>
|
||||
<select
|
||||
name="team-size"
|
||||
id="team-size"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required={selectedTeamType === 'company'}
|
||||
disabled={isLoading}
|
||||
value={teamSize}
|
||||
onChange={(e) =>
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-between gap-2 md:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<NextButton
|
||||
isLoading={isLoading}
|
||||
text={'Next Step'}
|
||||
type={'submit'}
|
||||
loadingMessage={'Creating team ..'}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
70
src/components/CreateTeam/Step2.tsx
Normal file
70
src/components/CreateTeam/Step2.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { RoadmapSelector, type TeamResourceConfig } from './RoadmapSelector';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step2Props = {
|
||||
team: TeamDocument;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
export function Step2(props: Step2Props) {
|
||||
const { team, onBack, onNext, teamResourceConfig, setTeamResourceConfig } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-1 md:mb-1.5 text-lg md:text-2xl font-bold">Select Roadmaps</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
You can always add and customize your roadmaps later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RoadmapSelector
|
||||
teamId={team._id!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
|
||||
<div className={'flex gap-2'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
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'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={teamResourceConfig.length === 0}
|
||||
onClick={onNext}
|
||||
className={
|
||||
'rounded-md border bg-black px-4 py-2 text-white disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
Next Step
|
||||
<span className="ml-1">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
198
src/components/CreateTeam/Step3.tsx
Normal file
198
src/components/CreateTeam/Step3.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { type AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type Step3Props = {
|
||||
team?: TeamDocument;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type InviteType = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: AllowedRoles;
|
||||
};
|
||||
|
||||
function generateId() {
|
||||
return `${new Date().getTime()}`;
|
||||
}
|
||||
|
||||
export function Step3(props: Step3Props) {
|
||||
const { onNext, onBack, team } = props;
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [invitingTeam, setInvitingTeam] = useState(false);
|
||||
const emailInputRef = useRef(null);
|
||||
|
||||
const [users, setUsers] = useState<InviteType[]>([
|
||||
{
|
||||
id: generateId(),
|
||||
email: '',
|
||||
role: 'member',
|
||||
},
|
||||
]);
|
||||
|
||||
async function inviteTeam() {
|
||||
setInvitingTeam(true);
|
||||
const { error, response } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-invite-team/${team?._id}`,
|
||||
{
|
||||
members: users,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong');
|
||||
setInvitingTeam(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
onNext();
|
||||
}
|
||||
|
||||
function focusLastEmailInput() {
|
||||
if (!emailInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
(emailInputRef.current as HTMLInputElement).focus();
|
||||
}
|
||||
|
||||
function onSubmit(e: any) {
|
||||
e.preventDefault();
|
||||
|
||||
inviteTeam().finally(() => null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
focusLastEmailInput();
|
||||
}, [users.length]);
|
||||
|
||||
return (
|
||||
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
|
||||
<div 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>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-1">
|
||||
{users.map((user, userCounter) => {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-2" key={user.id}>
|
||||
<input
|
||||
ref={userCounter === users.length - 1 ? emailInputRef : null}
|
||||
autoFocus={true}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
id="email"
|
||||
placeholder="Email"
|
||||
value={user.email}
|
||||
onChange={(e) => {
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
email: (e.target as HTMLInputElement)?.value,
|
||||
};
|
||||
}
|
||||
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
className="flex-grow rounded-md border border-gray-200 bg-white px-4 py-2 text-gray-900"
|
||||
/>
|
||||
<RoleDropdown
|
||||
selectedRole={user.role}
|
||||
setSelectedRole={(role: AllowedRoles) => {
|
||||
const newUsers = users.map((u) => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
return u;
|
||||
});
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
disabled={users.length <= 1}
|
||||
type="button"
|
||||
className="rounded-md border border-red-200 bg-white px-4 py-2 text-red-500 hover:bg-red-100 disabled:opacity-30"
|
||||
onClick={() => {
|
||||
setUsers(users.filter((u) => u.id !== user.id));
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{users.length <= 30 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setUsers([
|
||||
...users,
|
||||
{ id: generateId(), email: '', role: 'member' },
|
||||
]);
|
||||
}}
|
||||
type="button"
|
||||
className="mt-2 rounded-md border border-dashed border-gray-400 py-2 text-sm text-gray-500 hover:border-gray-500 hover:text-gray-800"
|
||||
>
|
||||
+ Add another
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 text-sm font-medium text-red-500" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-4 py-2 text-red-500'
|
||||
}
|
||||
>
|
||||
<span className="mr-1">←</span>
|
||||
Previous Step
|
||||
</button>
|
||||
<div className={'flex gap-2'}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
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'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
</button>
|
||||
<NextButton
|
||||
type={'submit'}
|
||||
isLoading={invitingTeam}
|
||||
text={'Send Invites'}
|
||||
loadingMessage={'Updating team ..'}
|
||||
hasNextArrow={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
26
src/components/CreateTeam/Step4.tsx
Normal file
26
src/components/CreateTeam/Step4.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step4Props = {
|
||||
team: TeamDocument;
|
||||
};
|
||||
|
||||
export function Step4({ team }: Step4Props) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
|
||||
<div className="mb-1 flex flex-col items-center">
|
||||
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
|
||||
<h2 className="mb-2 text-2xl font-bold">Team Created</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
Your team has been created. Happy learning!
|
||||
</p>
|
||||
<a
|
||||
href={`/team/progress?t=${team._id}`}
|
||||
className="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
|
||||
>
|
||||
View Team
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
src/components/CreateTeam/UpdateTeamResourceModal.tsx
Normal file
215
src/components/CreateTeam/UpdateTeamResourceModal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { renderTopicProgress } from '../../lib/resource-progress';
|
||||
import '../FrameRenderer/FrameRenderer.css';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $currentTeam } from '../../stores/team';
|
||||
|
||||
export type ProgressMapProps = {
|
||||
teamId: string;
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
defaultRemovedItems?: string[];
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function UpdateTeamResourceModal(props: ProgressMapProps) {
|
||||
const {
|
||||
defaultRemovedItems = [],
|
||||
resourceId,
|
||||
resourceType,
|
||||
teamId,
|
||||
setTeamResourceConfig,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const containerEl = useRef<HTMLDivElement>(null);
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const [removedItems, setRemovedItems] =
|
||||
useState<string[]>(defaultRemovedItems);
|
||||
|
||||
const currentTeam = useStore($currentTeam);
|
||||
|
||||
useEffect(() => {
|
||||
function onTopicClick(e: any) {
|
||||
const groupEl = e.target.closest('.clickable-group');
|
||||
const groupId = groupEl?.dataset?.groupId;
|
||||
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
if (removedItems.includes(normalizedGroupId)) {
|
||||
setRemovedItems((prev) =>
|
||||
prev.filter((id) => id !== normalizedGroupId)
|
||||
);
|
||||
renderTopicProgress(normalizedGroupId, 'reset' as any);
|
||||
} else {
|
||||
setRemovedItems((prev) => [...prev, normalizedGroupId]);
|
||||
renderTopicProgress(normalizedGroupId, 'removed');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', onTopicClick);
|
||||
return () => {
|
||||
document.removeEventListener('click', onTopicClick);
|
||||
};
|
||||
}, [removedItems]);
|
||||
|
||||
let resourceJsonUrl = 'https://roadmap.sh';
|
||||
if (resourceType === 'roadmap') {
|
||||
resourceJsonUrl += `/${resourceId}.json`;
|
||||
} else {
|
||||
resourceJsonUrl += `/best-practices/${resourceId}.json`;
|
||||
}
|
||||
|
||||
async function renderResource(jsonUrl: string) {
|
||||
const res = await fetch(jsonUrl);
|
||||
const json = await res.json();
|
||||
const svg = await wireframeJSONToSVG(json, {
|
||||
fontURL: '/fonts/balsamiq.woff2',
|
||||
});
|
||||
|
||||
containerEl.current?.replaceChildren(svg);
|
||||
|
||||
// Render team configuration
|
||||
removedItems.forEach((topicId: string) => {
|
||||
renderTopicProgress(topicId, 'removed');
|
||||
});
|
||||
}
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
async function onSaveChanges() {
|
||||
if (removedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
const { error, response } = await httpPut<TeamResourceConfig>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-team-resource-config/${teamId}`,
|
||||
{
|
||||
teamId: teamId,
|
||||
resourceId: resourceId,
|
||||
resourceType: resourceType,
|
||||
removed: removedItems,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error adding roadmap');
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
onClose();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!containerEl.current ||
|
||||
!resourceJsonUrl ||
|
||||
!resourceId ||
|
||||
!resourceType ||
|
||||
!teamId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderResource(resourceJsonUrl)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('Something went wrong. Please try again!');
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div 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'
|
||||
}
|
||||
ref={popupBodyEl}
|
||||
className="popup-body relative rounded-lg bg-white shadow"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'sticky top-0 mb-3 rounded-2xl border-4 border-white bg-black p-4'
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-gray-300">
|
||||
Click and select the items to remove from the roadmap.
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-1.5">
|
||||
<button
|
||||
disabled={removedItems.length === 0}
|
||||
onClick={() =>
|
||||
onSaveChanges().finally(() => setIsUpdating(false))
|
||||
}
|
||||
className={
|
||||
'rounded-md bg-blue-600 px-2.5 py-1.5 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400'
|
||||
}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<span className={'flex items-center gap-1.5'}>
|
||||
<Spinner
|
||||
className="h-3 w-3"
|
||||
innerFill="white"
|
||||
isDualRing={false}
|
||||
/>{' '}
|
||||
Saving ..
|
||||
</span>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-gray-600 px-2.5 py-1.5 text-sm text-white hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resource-svg-wrap" ref={containerEl} className="px-4"></div>
|
||||
|
||||
{isLoading && (
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/components/DeleteAccount/DeleteAccount.astro
Normal file
16
src/components/DeleteAccount/DeleteAccount.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import DeleteAccountPopup from "./DeleteAccountPopup.astro";
|
||||
---
|
||||
<DeleteAccountPopup />
|
||||
|
||||
<h2 class='text-xl font-bold sm:text-2xl'>Delete Account</h2>
|
||||
<p class='mt-2 text-gray-400'>
|
||||
Permanently remove your account from the roadmap.sh. This cannot be undone and all your progress and data will be lost.
|
||||
</p>
|
||||
|
||||
<button
|
||||
data-popup='delete-account-popup'
|
||||
class="mt-4 w-full rounded-lg bg-red-600 py-2 text-base font-regular text-white outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
89
src/components/DeleteAccount/DeleteAccountForm.tsx
Normal file
89
src/components/DeleteAccount/DeleteAccountForm.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { type FormEvent, useEffect, useState } from 'react';
|
||||
import { httpDelete } from '../../lib/http';
|
||||
import { logout } from '../Navigation/navigation';
|
||||
|
||||
export function DeleteAccountForm() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
if (confirmationText.toUpperCase() !== 'DELETE') {
|
||||
setError('Verification text does not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-account`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
logout();
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
|
||||
const deleteAccountPopup = document.getElementById('delete-account-popup');
|
||||
deleteAccountPopup?.classList.add('hidden');
|
||||
deleteAccountPopup?.classList.remove('flex');
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="my-4">
|
||||
<input
|
||||
type="text"
|
||||
name="delete-account"
|
||||
id="delete-account"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Type "delete" to confirm'}
|
||||
required
|
||||
autoFocus
|
||||
value={confirmationText}
|
||||
onInput={(e) =>
|
||||
setConfirmationText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || confirmationText.toUpperCase() !== 'DELETE'}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
17
src/components/DeleteAccount/DeleteAccountPopup.astro
Normal file
17
src/components/DeleteAccount/DeleteAccountPopup.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import { DeleteAccountForm } from './DeleteAccountForm';
|
||||
---
|
||||
|
||||
<Popup id='delete-account-popup' title='Delete Account' subtitle=''>
|
||||
<div class='-mt-2.5'>
|
||||
<p>
|
||||
This will permanently delete your account and all your associated data
|
||||
including your progress.
|
||||
</p>
|
||||
|
||||
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
|
||||
|
||||
<DeleteAccountForm client:only="react" />
|
||||
</div>
|
||||
</Popup>
|
||||
134
src/components/DeleteTeamPopup.tsx
Normal file
134
src/components/DeleteTeamPopup.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
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';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { useKeydown } from '../hooks/use-keydown';
|
||||
import { useToast } from '../hooks/use-toast';
|
||||
|
||||
type DeleteTeamPopupProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
|
||||
const { onClose } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const popupBodyEl = useRef<HTMLDivElement>(null);
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirmationText, setConfirmationText] = useState('');
|
||||
const { teamId } = useTeamId();
|
||||
|
||||
useOutsideClick(popupBodyEl, () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
onClose();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
inputEl.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
if (confirmationText.toUpperCase() !== 'DELETE') {
|
||||
setError('Verification text does not match');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpDelete<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-team/${teamId}`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Team deleted successfully');
|
||||
window.location.href = '/account';
|
||||
};
|
||||
|
||||
const handleClosePopup = () => {
|
||||
setIsLoading(false);
|
||||
setError('');
|
||||
setConfirmationText('');
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div 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"
|
||||
>
|
||||
<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 className="-mb-2 mt-3 text-base font-medium text-black">
|
||||
Please type "delete" to confirm.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="my-4">
|
||||
<input
|
||||
ref={inputEl}
|
||||
type="text"
|
||||
name="delete-account"
|
||||
id="delete-account"
|
||||
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
|
||||
placeholder={'Type "delete" to confirm'}
|
||||
required
|
||||
autoFocus
|
||||
value={confirmationText}
|
||||
onInput={(e) =>
|
||||
setConfirmationText((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
onClick={handleClosePopup}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
isLoading || confirmationText.toUpperCase() !== 'DELETE'
|
||||
}
|
||||
className="flex-grow cursor-pointer rounded-lg bg-red-500 py-2 text-white disabled:opacity-40"
|
||||
>
|
||||
{isLoading ? 'Please wait ..' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
45
src/components/FeaturedItems/FavoriteIcon.tsx
Normal file
45
src/components/FeaturedItems/FavoriteIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
type FavoriteIconProps = {
|
||||
isFavorite?: boolean;
|
||||
};
|
||||
|
||||
export function FavoriteIcon(props: FavoriteIconProps) {
|
||||
const { isFavorite } = props;
|
||||
|
||||
if (!isFavorite) {
|
||||
return (
|
||||
<svg
|
||||
width="8"
|
||||
height="10"
|
||||
viewBox="0 0 8 10"
|
||||
fill="none"
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="8"
|
||||
height="10"
|
||||
viewBox="0 0 8 10"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,23 @@
|
||||
---
|
||||
import AstroIcon from '../AstroIcon.astro';
|
||||
import { MarkFavorite } from './MarkFavorite';
|
||||
export interface FeaturedItemType {
|
||||
isUpcoming?: boolean;
|
||||
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
|
||||
@@ -20,16 +29,28 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
]}
|
||||
href={url}
|
||||
>
|
||||
<span class='text-slate-400 relative z-20'>
|
||||
<span class='relative z-20 text-slate-400'>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
{
|
||||
allowBookmark && (
|
||||
<MarkFavorite
|
||||
resourceId={url.split('/').pop()!}
|
||||
resourceType={
|
||||
url.includes('best-practices') ? 'best-practice' : 'roadmap'
|
||||
}
|
||||
client:only='react'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isNew && (
|
||||
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-purple-300 flex items-center'>
|
||||
<span class='flex h-2 w-2 mr-1.5'>
|
||||
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-purple-400 opacity-75' />
|
||||
<span class='relative inline-flex rounded-full h-2 w-2 bg-purple-500' />
|
||||
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
|
||||
<span class='mr-1.5 flex h-2 w-2'>
|
||||
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
|
||||
</span>
|
||||
New
|
||||
</span>
|
||||
@@ -38,14 +59,17 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
|
||||
{
|
||||
isUpcoming && (
|
||||
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-slate-500 flex items-center'>
|
||||
<span class='flex h-2 w-2 mr-1.5'>
|
||||
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-slate-500 opacity-75' />
|
||||
<span class='relative inline-flex rounded-full h-2 w-2 bg-slate-600' />
|
||||
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-slate-500'>
|
||||
<span class='mr-1.5 flex h-2 w-2'>
|
||||
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-slate-500 opacity-75' />
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-slate-600' />
|
||||
</span>
|
||||
Upcoming
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<span data-progress class="z-10 bg-[#172a3a] absolute top-0 left-0 bottom-0 duration-300 transition-[width] w-0"></span>
|
||||
<span
|
||||
data-progress
|
||||
class='absolute bottom-0 left-0 top-0 z-10 w-0 bg-[#172a3a] transition-[width] duration-300'
|
||||
></span>
|
||||
</a>
|
||||
|
||||
@@ -4,15 +4,16 @@ import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
|
||||
export interface Props {
|
||||
featuredItems: FeaturedItemType[];
|
||||
heading: string;
|
||||
allowBookmark?: boolean;
|
||||
}
|
||||
|
||||
const { featuredItems, heading } = Astro.props;
|
||||
const { featuredItems, heading, 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 +23,7 @@ const { featuredItems, heading } = Astro.props;
|
||||
featuredItems.map((featuredItem) => (
|
||||
<li>
|
||||
<FeaturedItem
|
||||
allowBookmark={allowBookmark}
|
||||
text={featuredItem.text}
|
||||
url={featuredItem.url}
|
||||
isNew={featuredItem.isNew}
|
||||
|
||||
109
src/components/FeaturedItems/MarkFavorite.tsx
Normal file
109
src/components/FeaturedItems/MarkFavorite.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
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';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { FavoriteIcon } from './FavoriteIcon';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
type MarkFavoriteType = {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
favorite?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MarkFavorite({
|
||||
resourceId,
|
||||
resourceType,
|
||||
favorite,
|
||||
className,
|
||||
}: MarkFavoriteType) {
|
||||
const isAuthenticated = isLoggedIn();
|
||||
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
|
||||
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(
|
||||
isAuthenticated ? (favorite ?? localStorage.getItem(localStorageKey) === '1') : false
|
||||
);
|
||||
|
||||
async function toggleFavoriteHandler(e: MouseEvent<HTMLButtonElement>) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await httpPatch<{ status: 'ok' }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-mark-favorite`,
|
||||
{
|
||||
resourceType,
|
||||
resourceId,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
toast.error('Failed to update favorite status');
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatching an event instead of setting the state because
|
||||
// MarkFavorite component is used in the HeroSection as well
|
||||
// as featured items section. We will let the custom event
|
||||
// listener set the update `useEffect`
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId,
|
||||
resourceType,
|
||||
isFavorite: !isFavorite,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('refresh-favorites', {}));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: Event) => {
|
||||
const {
|
||||
resourceId: id,
|
||||
resourceType: type,
|
||||
isFavorite: fav,
|
||||
} = (e as CustomEvent).detail;
|
||||
if (id === resourceId && type === resourceType) {
|
||||
setIsFavorite(fav);
|
||||
localStorage.setItem(localStorageKey, fav ? '1' : '0');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mark-favorite', listener);
|
||||
return () => {
|
||||
window.removeEventListener('mark-favorite', listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
onClick={toggleFavoriteHandler}
|
||||
tabIndex={-1}
|
||||
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${
|
||||
className || 'absolute right-1.5 top-1.5 z-30 focus:outline-0'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <FavoriteIcon isFavorite={isFavorite} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
146
src/components/Feedback/SubmitFeedbackPopup.tsx
Normal file
146
src/components/Feedback/SubmitFeedbackPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
@@ -24,16 +25,13 @@ import Icon from './AstroIcon.astro';
|
||||
href='/videos'>Videos</a
|
||||
>
|
||||
<a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer nofollow'
|
||||
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='https://cottonbureau.com/people/roadmapsh'>Store</a
|
||||
href='/about'>FAQs</a
|
||||
>
|
||||
<a
|
||||
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
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'>
|
||||
@@ -64,11 +62,26 @@ import Icon from './AstroIcon.astro';
|
||||
<p>
|
||||
© roadmap.sh
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a href='/about' class='hover:text-white'>FAQs</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a href='/terms' class='hover:text-white'>Terms</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a href='/privacy' class='hover:text-white'>Privacy</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
aria-label="Subscribe to YouTube channel"
|
||||
href='https://youtube.com/theroadmap?sub_confirmation=1'
|
||||
target='_blank'
|
||||
class='hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
|
||||
</a>
|
||||
<a
|
||||
aria-label="Follow on Twitter"
|
||||
href='https://twitter.com/roadmapsh'
|
||||
target='_blank'
|
||||
class='ml-2 hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,7 +52,12 @@ svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text, svg .skipped text {
|
||||
svg .done rect[stroke="rgb(255,229,153)"] {
|
||||
stroke: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text,
|
||||
svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -58,7 +66,7 @@ svg .learning rect {
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69!important;
|
||||
fill: #496b69 !important;
|
||||
}
|
||||
|
||||
svg .learning rect[fill='rgb(51,51,51)'] + text,
|
||||
@@ -72,13 +80,28 @@ svg .learning text {
|
||||
|
||||
svg .clickable-group.done[data-group-id^='check:'] rect {
|
||||
fill: gray !important;
|
||||
stroke: gray;
|
||||
stroke: gray !important;
|
||||
}
|
||||
|
||||
.clickable-group rect {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
svg .removed rect {
|
||||
fill: #fdfdfd !important;
|
||||
stroke: #c4c4c4 !important;
|
||||
}
|
||||
|
||||
svg .removed text {
|
||||
fill: #9c9c9c !important;
|
||||
}
|
||||
|
||||
svg .removed g,
|
||||
svg .removed circle,
|
||||
svg .removed path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/************************************
|
||||
Aspect ratio implementation
|
||||
*************************************/
|
||||
@@ -109,3 +132,14 @@ svg .clickable-group.done[data-group-id^='check:'] rect {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#customized-roadmap #resource-svg-wrap g:not([class]),
|
||||
#customized-roadmap #resource-svg-wrap circle,
|
||||
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
|
||||
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*.clickable-group:hover {*/
|
||||
/* cursor: url(/images/cursors/add.svg) 5 5, move;*/
|
||||
/*}*/
|
||||
|
||||
@@ -2,13 +2,18 @@ import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
refreshProgressCounters,
|
||||
renderResourceProgress,
|
||||
ResourceType,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
resourceType: ResourceType | string;
|
||||
jsonUrl: string;
|
||||
loaderHTML: string | null;
|
||||
|
||||
@@ -28,8 +33,10 @@ export class Renderer {
|
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this);
|
||||
this.jsonToSvg = this.jsonToSvg.bind(this);
|
||||
this.handleSvgClick = this.handleSvgClick.bind(this);
|
||||
this.handleSvgRightClick = this.handleSvgRightClick.bind(this);
|
||||
this.prepareConfig = this.prepareConfig.bind(this);
|
||||
this.switchRoadmap = this.switchRoadmap.bind(this);
|
||||
this.updateTopicStatus = this.updateTopicStatus.bind(this);
|
||||
}
|
||||
|
||||
get loaderEl() {
|
||||
@@ -161,6 +168,57 @@ export class Renderer {
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||
}
|
||||
|
||||
updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType as ResourceType,
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleSvgRightClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
handleSvgClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
@@ -170,6 +228,10 @@ export class Renderer {
|
||||
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (targetGroup.classList.contains('removed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^ext_link/.test(groupId)) {
|
||||
const externalLink = groupId.replace('ext_link:', '');
|
||||
|
||||
@@ -209,6 +271,28 @@ export class Renderer {
|
||||
// Remove sorting prefix from groupId
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.altKey) {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(`${this.resourceType}.topic.click`, {
|
||||
detail: {
|
||||
@@ -223,7 +307,7 @@ export class Renderer {
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
window.addEventListener('click', this.handleSvgClick);
|
||||
// window.addEventListener('contextmenu', this.handleSvgClick);
|
||||
window.addEventListener('contextmenu', this.handleSvgRightClick);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
src/components/Friends/EmptyFriends.tsx
Normal file
54
src/components/Friends/EmptyFriends.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import UserPlusIcon from '../../icons/user-plus.svg';
|
||||
import CopyIcon from '../../icons/copy.svg';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
|
||||
type EmptyFriendsProps = {
|
||||
befriendUrl: string;
|
||||
};
|
||||
|
||||
export function EmptyFriends(props: EmptyFriendsProps) {
|
||||
const { befriendUrl } = props;
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
|
||||
return (
|
||||
<div className="rounded-md">
|
||||
<div className="mx-auto flex flex-col items-center p-7 text-center">
|
||||
<img
|
||||
alt="no friends"
|
||||
src={UserPlusIcon.src}
|
||||
className="mb-2 h-[60px] w-[60px] opacity-10 sm:h-[120px] sm:w-[120px]"
|
||||
/>
|
||||
<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 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();
|
||||
copyText(befriendUrl);
|
||||
}}
|
||||
type="text"
|
||||
value={befriendUrl}
|
||||
className="w-full border-none bg-transparent px-1.5 outline-none"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
onClick={() => {
|
||||
copyText(befriendUrl);
|
||||
}}
|
||||
>
|
||||
<img src={CopyIcon.src} className="h-4 w-4" alt="Invite Friends" />
|
||||
{isCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
src/components/Friends/FriendProgressItem.tsx
Normal file
326
src/components/Friends/FriendProgressItem.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState } from 'react';
|
||||
import type { ListFriendsResponse } from './FriendsPage';
|
||||
import { DeleteUserIcon } from '../ReactIcons/DeleteUserIcon';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { httpDelete, httpPost } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
|
||||
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
||||
|
||||
type FriendProgressItemProps = {
|
||||
friend: ListFriendsResponse[0];
|
||||
onShowResourceProgress: (resourceId: string) => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
|
||||
export function FriendProgressItem(props: FriendProgressItemProps) {
|
||||
const { friend, onShowResourceProgress, onReload } = props;
|
||||
const toast = useToast();
|
||||
const [isConfirming, setIsConfirming] =
|
||||
useState<ListFriendsResponse[0]['status']>();
|
||||
|
||||
async function deleteFriend(userId: string, successMessage: string) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpDelete(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(successMessage);
|
||||
onReload();
|
||||
}
|
||||
|
||||
async function addFriend(userId: string, successMessage: string) {
|
||||
pageProgressMessage.set('Please wait...');
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
|
||||
{}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(successMessage);
|
||||
onReload();
|
||||
}
|
||||
|
||||
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
|
||||
return b.done - a.done;
|
||||
});
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const status = friend.status;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex h-full min-h-[270px] flex-col overflow-hidden rounded-md border`}
|
||||
key={friend.userId}
|
||||
>
|
||||
<div className={`relative flex items-center gap-3 border-b p-3`}>
|
||||
<img
|
||||
src={
|
||||
friend.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${friend.avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
alt={friend.name || ''}
|
||||
className="h-8 w-8 rounded-full"
|
||||
/>
|
||||
<div className="inline-grid w-full">
|
||||
<h3 className="truncate font-medium">{friend.name}</h3>
|
||||
<p className="truncate text-sm text-gray-500">{friend.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{friend.status === 'accepted' && (
|
||||
<>
|
||||
<div className="relative flex grow flex-col space-y-2 p-3">
|
||||
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onShowResourceProgress(progress.resourceId)}
|
||||
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
|
||||
key={progress.resourceId}
|
||||
>
|
||||
<span className="relative z-10 flex items-center justify-between text-sm">
|
||||
<span className="inline-grid">
|
||||
<span className={'truncate'}>{progress.title}</span>
|
||||
</span>
|
||||
<span className="ml-1.5 shrink-0 text-xs text-gray-400">
|
||||
{progress.done} / {progress.total}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
|
||||
style={{
|
||||
width: `${(progress.done / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{roadmaps.length > 4 && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className={'text-xs text-gray-400 underline'}
|
||||
>
|
||||
+ {roadmaps.length - 4} more
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className={'text-sm text-gray-400 underline'}
|
||||
>
|
||||
- Show less
|
||||
</button>
|
||||
)}
|
||||
|
||||
{roadmaps.length === 0 && (
|
||||
<div className="text-sm text-gray-500">No progress</div>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
{isConfirming !== 'accepted' && (
|
||||
<button
|
||||
className="flex w-full items-center justify-center border-t py-2 text-sm font-medium text-red-700 hover:bg-red-50/50 hover:text-red-500"
|
||||
onClick={() => {
|
||||
setIsConfirming('accepted');
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="mr-1 h-4 w-4" />
|
||||
Remove Friend
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming === 'accepted' && (
|
||||
<span className="flex w-full items-center justify-center border-t py-2 text-sm text-red-700">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
|
||||
onClick={() => {
|
||||
deleteFriend(friend.userId, 'Friend removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
|
||||
onClick={() => {
|
||||
setIsConfirming(undefined);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
|
||||
{friend.status === 'rejected' && (
|
||||
<>
|
||||
<div
|
||||
className={'flex w-full flex-grow items-center justify-center'}
|
||||
>
|
||||
<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 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"
|
||||
onClick={() => {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{friend.status === 'got_rejected' && (
|
||||
<>
|
||||
<div
|
||||
className={'flex w-full flex-grow items-center justify-center'}
|
||||
>
|
||||
<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 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={() => {
|
||||
deleteFriend(friend.userId, 'Friend request removed').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="mr-1 h-4 w-4" />
|
||||
Delete Request
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{friend.status === 'sent' && (
|
||||
<>
|
||||
<div
|
||||
className={'flex w-full flex-grow items-center justify-center'}
|
||||
>
|
||||
<span className=" flex flex-col items-center text-green-500">
|
||||
<AddedUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
|
||||
Request Sent
|
||||
</span>
|
||||
</div>
|
||||
<>
|
||||
{isConfirming !== 'sent' && (
|
||||
<button
|
||||
className="flex w-full items-center justify-center border-t py-2 text-sm font-medium text-red-700 hover:bg-red-50/50 hover:text-red-500"
|
||||
onClick={() => {
|
||||
setIsConfirming('sent');
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="mr-1 h-4 w-4" />
|
||||
Withdraw Request
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming === 'sent' && (
|
||||
<span className="flex w-full items-center justify-center border-t py-2 text-sm text-red-700">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request withdrawn'
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
|
||||
onClick={() => {
|
||||
setIsConfirming(undefined);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
|
||||
{friend.status === 'received' && (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-grow flex-col items-center justify-center px-4'
|
||||
}
|
||||
>
|
||||
<AddUserIcon additionalClasses="mr-2 h-8 w-8 mb-1 text-green-500" />
|
||||
<span className="mb-3 text-green-600">Request Received</span>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
addFriend(friend.userId, 'Friend request accepted').finally(
|
||||
() => {
|
||||
pageProgressMessage.set('');
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
deleteFriend(
|
||||
friend.userId,
|
||||
'Friend request rejected'
|
||||
).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
className="block w-full max-w-[150px] rounded-md border border-red-500 py-1 text-sm text-red-500"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
216
src/components/Friends/FriendsPage.tsx
Normal file
216
src/components/Friends/FriendsPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import type { FriendshipStatus } from '../Befriend';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { EmptyFriends } from './EmptyFriends';
|
||||
import { FriendProgressItem } from './FriendProgressItem';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import { UserProgressModal } from '../UserProgress/UserProgressModal';
|
||||
import { InviteFriendPopup } from './InviteFriendPopup';
|
||||
|
||||
type FriendResourceProgress = {
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListFriendsResponse = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
status: FriendshipStatus;
|
||||
roadmaps: FriendResourceProgress[];
|
||||
bestPractices: FriendResourceProgress[];
|
||||
}[];
|
||||
|
||||
type GroupingType = {
|
||||
label: string;
|
||||
value: 'active' | 'requests' | 'sent';
|
||||
statuses: FriendshipStatus[];
|
||||
};
|
||||
|
||||
const groupingTypes: GroupingType[] = [
|
||||
{ label: 'Active', value: 'active', statuses: ['accepted'] },
|
||||
{ label: 'Requests', value: 'requests', statuses: ['received', 'rejected'] },
|
||||
{ label: 'Sent', value: 'sent', statuses: ['sent', 'got_rejected'] },
|
||||
];
|
||||
|
||||
export function FriendsPage() {
|
||||
const toast = useToast();
|
||||
|
||||
const [showInviteFriendPopup, setShowInviteFriendPopup] = useState(false);
|
||||
|
||||
const [showFriendProgress, setShowFriendProgress] = useState<{
|
||||
resourceId: string;
|
||||
friend: ListFriendsResponse[0];
|
||||
}>();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [selectedGrouping, setSelectedGrouping] =
|
||||
useState<GroupingType['value']>('active');
|
||||
|
||||
async function loadFriends() {
|
||||
const { response, error } = await httpGet<ListFriendsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setFriends(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFriends().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const user = useAuth();
|
||||
const baseUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
const befriendUrl = `${baseUrl}/befriend?u=${user?.id}`;
|
||||
|
||||
const selectedGroupingType = groupingTypes.find(
|
||||
(grouping) => grouping.value === selectedGrouping
|
||||
);
|
||||
|
||||
const filteredFriends = friends.filter((friend) =>
|
||||
selectedGroupingType?.statuses.includes(friend.status)
|
||||
);
|
||||
|
||||
const receivedRequests = friends.filter(
|
||||
(friend) => friend.status === 'received'
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!friends?.length) {
|
||||
return <EmptyFriends befriendUrl={befriendUrl} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showInviteFriendPopup && (
|
||||
<InviteFriendPopup
|
||||
befriendUrl={befriendUrl}
|
||||
onClose={() => setShowInviteFriendPopup(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFriendProgress && (
|
||||
<UserProgressModal
|
||||
userId={showFriendProgress.friend.userId}
|
||||
resourceId={showFriendProgress.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
onClose={() => setShowFriendProgress(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{groupingTypes.map((grouping) => {
|
||||
let requestCount = 0;
|
||||
if (grouping.value === 'requests') {
|
||||
requestCount = receivedRequests.length;
|
||||
}
|
||||
|
||||
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 '
|
||||
: ''
|
||||
} w-full sm:w-auto`}
|
||||
onClick={() => setSelectedGrouping(grouping.value)}
|
||||
>
|
||||
{grouping.label}
|
||||
{requestCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
|
||||
{requestCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowInviteFriendPopup(true);
|
||||
}}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredFriends.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{filteredFriends.map((friend) => (
|
||||
<FriendProgressItem
|
||||
friend={friend}
|
||||
onShowResourceProgress={(resourceId) => {
|
||||
setShowFriendProgress({
|
||||
resourceId,
|
||||
friend,
|
||||
});
|
||||
}}
|
||||
key={friend.userId}
|
||||
onReload={() => {
|
||||
pageProgressMessage.set('Reloading friends ..');
|
||||
loadFriends().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFriends.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<img
|
||||
src={UserIcon.src}
|
||||
alt="Empty Friends"
|
||||
className="mb-3 w-12 opacity-20"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{selectedGrouping === 'active' && 'No friends yet'}
|
||||
{selectedGrouping === 'sent' && 'No requests sent'}
|
||||
{selectedGrouping === 'requests' && 'No requests received'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Invite your friends to join you on Roadmap
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowInviteFriendPopup(true);
|
||||
}}
|
||||
className="mt-4 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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/Friends/InviteFriendPopup.tsx
Normal file
69
src/components/Friends/InviteFriendPopup.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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';
|
||||
|
||||
type InviteFriendPopupProps = {
|
||||
befriendUrl: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function InviteFriendPopup(props: InviteFriendPopupProps) {
|
||||
const { onClose, befriendUrl } = props;
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
|
||||
const popupBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClosePopup = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
useOutsideClick(popupBodyRef, handleClosePopup);
|
||||
|
||||
return (
|
||||
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyRef}
|
||||
className="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:mt-4">
|
||||
<input
|
||||
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: MouseEvent<HTMLInputElement>) => {
|
||||
(e?.target as HTMLInputElement)?.select();
|
||||
copyText(befriendUrl);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
onClick={() => {
|
||||
copyText(befriendUrl);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={CopyIcon.src}
|
||||
className="h-4 w-4"
|
||||
alt="Invite Friends"
|
||||
/>
|
||||
{isCopied ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/Friends/SidebarFriendsCounter.tsx
Normal file
45
src/components/Friends/SidebarFriendsCounter.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type GetFriendCountsResponse = {
|
||||
sentCount: number;
|
||||
acceptedCount: number;
|
||||
receivedCount: number;
|
||||
rejectedCount: number;
|
||||
gotRejectedCount: number;
|
||||
};
|
||||
|
||||
export function SidebarFriendsCounter() {
|
||||
const [friendCounts, setFriendCounts] = useState<GetFriendCountsResponse>();
|
||||
async function getFriendCounts() {
|
||||
const { response, error } = await httpGet<GetFriendCountsResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-friend-counts`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFriendCounts(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getFriendCounts().finally(() => null);
|
||||
}, []);
|
||||
|
||||
const pendingCount = friendCounts?.receivedCount || 0;
|
||||
if (!pendingCount) {
|
||||
return (
|
||||
<span className="relative mr-1 flex items-center">
|
||||
<span className="relative rounded-full bg-gray-200 p-1 text-xs" />
|
||||
<span className="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
|
||||
{pendingCount}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheckIcon } from './CheckIcon';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
@@ -25,6 +27,16 @@ function renderProgress(progressList: UserProgressResponse) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isFavorite: progress.isFavorite,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const totalDone = progress.done + progress.skipped;
|
||||
const percentageDone = (totalDone / progress.total) * 100;
|
||||
|
||||
@@ -37,6 +49,11 @@ function renderProgress(progressList: 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>([]);
|
||||
@@ -61,6 +78,7 @@ 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`
|
||||
@@ -84,6 +102,11 @@ export function FavoriteRoadmaps() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('refresh-favorites', loadProgress);
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
@@ -92,15 +115,14 @@ export function FavoriteRoadmaps() {
|
||||
|
||||
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 && <EmptyProgress title="Loading progress .." />}
|
||||
{!isLoading && progress.length == 0 && <EmptyProgress />}
|
||||
{!isLoading && progress.length > 0 && (
|
||||
<ProgressList progress={progress} />
|
||||
{progress.length > 0 && (
|
||||
<HeroRoadmaps customRoadmaps={[]} progress={progress} isLoading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
155
src/components/HeroSection/HeroRoadmaps.tsx
Normal file
155
src/components/HeroSection/HeroRoadmaps.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
showCustomRoadmaps?: boolean;
|
||||
customRoadmaps: any[]; // @fixme implement this
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function HeroRoadmaps(props: ProgressListProps) {
|
||||
const {
|
||||
progress,
|
||||
isLoading = false,
|
||||
customRoadmaps = [{} /* @fixme implement this */],
|
||||
showCustomRoadmaps = false,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
{
|
||||
<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>
|
||||
|
||||
{showCustomRoadmaps && (
|
||||
<div className="mt-5">
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title="Your custom roadmaps"
|
||||
/>
|
||||
}
|
||||
|
||||
{customRoadmaps.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
You haven't created any custom roadmaps yet.{' '}
|
||||
<button className="text-gray-500 underline underline-offset-2 hover:text-gray-400">
|
||||
Create one!
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{customRoadmaps.map((customRoadmap) => (
|
||||
<HeroRoadmap
|
||||
resourceId={'343434'}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={'Frontend Roadmap Revised'}
|
||||
percentageDone={50}
|
||||
url={`/r?${'34343434'}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,5 +24,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
their career.
|
||||
</p>
|
||||
</div>
|
||||
<FavoriteRoadmaps client:authenticated />
|
||||
<FavoriteRoadmaps client:only="react" />
|
||||
</div>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { UserProgressResponse } from './FavoriteRoadmaps';
|
||||
import { CheckIcon } from './CheckIcon';
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
};
|
||||
|
||||
export function ProgressList(props: ProgressListProps) {
|
||||
const { progress } = props;
|
||||
|
||||
return (
|
||||
<div className="relative pt-4 sm:pt-7 pb-12">
|
||||
<p className="mb-4 flex items-center text-sm text-gray-400">
|
||||
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
|
||||
<span className='hidden sm:inline'>Your progress and favorite roadmaps.</span>
|
||||
<span className='inline sm:hidden'>Your progress and favorite roadmaps.</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{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
|
||||
href={url}
|
||||
className="relative flex flex-col rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300 overflow-hidden"
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import Icon from '../AstroIcon.astro';
|
||||
</button>
|
||||
|
||||
<div
|
||||
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
class='absolute right-0 z-50 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
data-account-dropdown
|
||||
>
|
||||
<ul>
|
||||
@@ -30,6 +30,22 @@ import Icon from '../AstroIcon.astro';
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li class='px-1'>
|
||||
<a
|
||||
href='/account/friends'
|
||||
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
>
|
||||
Friends
|
||||
</a>
|
||||
</li>
|
||||
<li class='px-1'>
|
||||
<a
|
||||
href='/team'
|
||||
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
>
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
<li class='px-1'>
|
||||
<button
|
||||
class='block w-full rounded px-4 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
|
||||
@@ -24,10 +24,10 @@ 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
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from "../../lib/jwt";
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
export function logout() {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
Cookies.remove(TOKEN_COOKIE_NAME, {
|
||||
path: '/',
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
|
||||
// Reloading will automatically redirect the user if required
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -15,13 +19,22 @@ 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();
|
||||
logout();
|
||||
} else if (dataset.showMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
|
||||
} else if (dataset.closeMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
|
||||
} else if (
|
||||
accountDropdown &&
|
||||
!target?.closest('[data-account-dropdown]') &&
|
||||
!accountDropdown.classList.contains('hidden')
|
||||
) {
|
||||
accountDropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -35,10 +48,10 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-command-menu]')
|
||||
?.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('command.k'));
|
||||
});
|
||||
.querySelector('[data-command-menu]')
|
||||
?.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('command.k'));
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
|
||||
109
src/components/Notification/NotificationPage.tsx
Normal file
109
src/components/Notification/NotificationPage.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet, httpPatch, httpPost } from '../../lib/http';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
|
||||
import XIcon from '../../icons/close-dark.svg';
|
||||
import AcceptIcon from '../../icons/accept.svg';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
interface NotificationList extends TeamMemberDocument {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function NotificationPage() {
|
||||
const toast = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [notifications, setNotifications] = useState<NotificationList[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const lostNotifications = async () => {
|
||||
const { error, response } = await httpGet<NotificationList[]>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-invitation-list`
|
||||
);
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Something went wrong');
|
||||
return;
|
||||
}
|
||||
|
||||
setNotifications(response);
|
||||
};
|
||||
|
||||
async function respondInvitation(status: 'accept' | 'reject', inviteId: string) {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
const { response, error } = await httpPatch<{ teamId: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-respond-invite/${inviteId}`, {
|
||||
status
|
||||
});
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Something went wrong')
|
||||
setIsLoading(false)
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'accept') {
|
||||
window.location.href = `/team/progress?t=${response.teamId}`;
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('refresh-notification', {
|
||||
detail: {
|
||||
count: notifications.length - 1
|
||||
}
|
||||
}));
|
||||
setNotifications(notifications.filter((notification) => notification._id !== inviteId));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
lostNotifications().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div 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>
|
||||
{
|
||||
notifications.length === 0 && (
|
||||
<div className="flex items-center justify-center mt-6">
|
||||
<p className="text-gray-400">
|
||||
No notifications, you can <a href="/team/new" className="text-blue-500 underline hover:no-underline">create a team</a> and invite your friends to join.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="space-y-4">
|
||||
{notifications.map((notification) => (
|
||||
<div className="flex items-center justify-between rounded-md border p-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{notification.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button type="button"
|
||||
disabled={isLoading}
|
||||
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
|
||||
onClick={() => respondInvitation('accept', notification?._id!)}
|
||||
>
|
||||
<img src={AcceptIcon.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 alt={'Close'} src={XIcon.src} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
@@ -36,7 +36,8 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
currentPath === '/roadmaps' ||
|
||||
currentPath.startsWith('/guides') ||
|
||||
currentPath.startsWith('/videos') ||
|
||||
currentPath.startsWith('/account')
|
||||
currentPath.startsWith('/account') ||
|
||||
currentPath.startsWith('/team')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -76,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',
|
||||
@@ -94,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>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export class Popup {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
popupEl.classList.remove('hidden');
|
||||
popupEl.classList.add('flex');
|
||||
const focusEl = popupEl.querySelector('[autofocus]');
|
||||
|
||||
47
src/components/ProgressHelpPopup.astro
Normal file
47
src/components/ProgressHelpPopup.astro
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Popup from './Popup/Popup.astro';
|
||||
---
|
||||
|
||||
<Popup id='progress-help' title='' subtitle=''>
|
||||
<div class='-mt-2.5'>
|
||||
<p class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
|
||||
Track your Progress
|
||||
</p>
|
||||
<p class='text-sm leading-4 text-gray-600'>
|
||||
Login and use one of the options listed below.
|
||||
</p>
|
||||
|
||||
<div class='mt-4 flex flex-col gap-1.5'>
|
||||
<div class='rounded-md border px-3 py-3 text-gray-500'>
|
||||
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
|
||||
>Option 1</span
|
||||
>
|
||||
<p class='text-sm'>
|
||||
Click the roadmap topics and use <span class='underline'
|
||||
>Update Progress</span
|
||||
> dropdown to update your progress.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='rounded-md border border-yellow-300 bg-yellow-50 px-3 py-3 text-gray-500'>
|
||||
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
|
||||
>Option 2</span
|
||||
>
|
||||
<p class='text-sm'>Use the keyboard shortcuts listed below.</p>
|
||||
|
||||
<ul class="flex flex-col gap-1 mt-3 mb-1.5">
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Right Mouse Click</kbd> to mark as Done.
|
||||
</li>
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Shift</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as in progress.
|
||||
</li>
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Option / Alt</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as skipped.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
142
src/components/Questions/PrismAtom.css
Normal file
142
src/components/Questions/PrismAtom.css
Normal 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;
|
||||
}
|
||||
125
src/components/Questions/QuestionCard.tsx
Normal file
125
src/components/Questions/QuestionCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import type { QuestionType } from '../../lib/question-group';
|
||||
import { markdownToHtml } from '../../lib/markdown';
|
||||
import Prism from 'prismjs';
|
||||
import './PrismAtom.css';
|
||||
|
||||
type QuestionCardProps = {
|
||||
question: QuestionType;
|
||||
};
|
||||
|
||||
export function QuestionCard(props: QuestionCardProps) {
|
||||
const { question } = props;
|
||||
|
||||
const [isAnswerVisible, setIsAnswerVisible] = useState<boolean>(false);
|
||||
const answerRef = useRef<HTMLDivElement>(null);
|
||||
const questionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// set the height of the question width to the height of the answer
|
||||
// width if the answer is visible and the question height is less than
|
||||
// the answer height
|
||||
if (isAnswerVisible) {
|
||||
Prism.highlightAll();
|
||||
|
||||
const answerHeight = answerRef.current?.clientHeight || 0;
|
||||
const questionHeight = questionRef.current?.clientHeight || 0;
|
||||
|
||||
if (answerHeight > questionHeight) {
|
||||
questionRef.current!.style.height = `${answerHeight}px`;
|
||||
}
|
||||
} else {
|
||||
questionRef.current!.style.height = `auto`;
|
||||
}
|
||||
|
||||
// if the user has scrolled down and the top of the answer is not
|
||||
// visible, scroll to the top of the answer
|
||||
const questionTop =
|
||||
(questionRef.current?.getBoundingClientRect().top || 0) - 147;
|
||||
if (questionTop < 0) {
|
||||
window.scrollTo({
|
||||
top: window.scrollY + questionTop - 10,
|
||||
});
|
||||
}
|
||||
}, [isAnswerVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsAnswerVisible(false);
|
||||
}, [question]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={questionRef}
|
||||
className={`flex flex-grow flex-col items-center justify-center py-5 sm:py-8`}
|
||||
>
|
||||
<div className="hidden text-gray-400 sm:block">
|
||||
{question.topics?.map((topic, counter) => {
|
||||
const totalTopics = question.topics?.length || 0;
|
||||
|
||||
return (
|
||||
<Fragment key={topic}>
|
||||
<span className="capitalize">{topic}</span>
|
||||
{counter !== totalTopics - 1 && (
|
||||
<span className="mx-2">·</span>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-[550px] flex-1 items-center justify-center py-3 sm:py-8">
|
||||
<p className="px-4 text-xl font-semibold !leading-snug text-black sm:text-3xl">
|
||||
{question.question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAnswerVisible(true);
|
||||
}}
|
||||
className="cursor-pointer text-sm text-gray-500 underline underline-offset-4 transition-colors hover:text-black sm:text-base"
|
||||
>
|
||||
Click to Reveal the Answer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={answerRef}
|
||||
className={`absolute left-0 right-0 flex flex-col items-center justify-center rounded-[7px] bg-neutral-100 py-4 text-sm leading-normal text-black transition-all duration-300 sm:py-8 sm:text-xl ${
|
||||
isAnswerVisible ? 'top-0 min-h-[248px] sm:min-h-[398px]' : 'top-full'
|
||||
}`}
|
||||
>
|
||||
{!question.isLongAnswer && (
|
||||
<div
|
||||
className={`mx-auto flex max-w-[600px] flex-grow flex-col items-center justify-center py-0 px-5 text-center text-base [&>p]:leading-relaxed sm:text-xl`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(question.answer, false),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{question.isLongAnswer && (
|
||||
<div
|
||||
className={`qa-answer prose prose-sm prose-quoteless mx-auto flex w-full max-w-[600px] flex-grow flex-col items-start justify-center py-0 px-4 text-left text-sm prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-pre:!mb-6 prose-pre:w-full prose-ul:my-2 prose-li:m-0 prose-li:mb-0.5 sm:px-5 sm:text-lg sm:prose-p:mb-4`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: markdownToHtml(question.answer, false),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-7 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAnswerVisible(false);
|
||||
}}
|
||||
className="cursor-pointer text-sm text-gray-500 underline underline-offset-4 transition-colors hover:text-black sm:text-base"
|
||||
>
|
||||
Hide the Answer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/components/Questions/QuestionFinished.tsx
Normal file
99
src/components/Questions/QuestionFinished.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
PartyPopper,
|
||||
RefreshCcw,
|
||||
SkipForward,
|
||||
Sparkles,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react';
|
||||
import type { QuestionProgressType } from './QuestionsList';
|
||||
|
||||
type ProgressStatButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ProgressStatButton(props: ProgressStatButtonProps) {
|
||||
const { icon, label, count, onClick, isDisabled = false } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
className="group relative text-sm sm:text-base flex flex-1 items-center overflow-hidden rounded-md sm:rounded-xl border border-gray-300 bg-white py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{icon}
|
||||
<span className="flex flex-grow justify-between">
|
||||
<span>{label}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
|
||||
<span className="absolute top-full left-0 right-0 flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
|
||||
Restart Asking
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type QuestionFinishedProps = {
|
||||
knowCount: number;
|
||||
didNotKnowCount: number;
|
||||
skippedCount: number;
|
||||
totalCount: number;
|
||||
onReset: (type: QuestionProgressType | 'reset') => void;
|
||||
};
|
||||
|
||||
export function QuestionFinished(props: QuestionFinishedProps) {
|
||||
const { knowCount, didNotKnowCount, skippedCount, totalCount, onReset } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow flex-col items-center justify-center px-4 sm:px-0">
|
||||
<PartyPopper className="mb-4 mt-10 h-14 w-14 text-gray-300 sm:mt-0 sm:h-24 sm:w-24" />
|
||||
<h1 className="text-lg font-semibold text-gray-700 sm:text-2xl">
|
||||
Questions Finished
|
||||
</h1>
|
||||
<p className="mt-0 text-sm text-gray-500 sm:mt-2 sm:text-base">
|
||||
Click below revisit{' '}
|
||||
<span className="hidden sm:inline">specific or all questions</span>{' '}
|
||||
<span className="inline sm:hidden">questions</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-5 mb-5 flex w-full flex-col gap-1.5 sm:gap-3 px-2 sm:flex-row sm:px-16">
|
||||
<ProgressStatButton
|
||||
icon={<ThumbsUp className="mr-1 h-4" />}
|
||||
label="Knew"
|
||||
count={knowCount}
|
||||
isDisabled={knowCount === 0}
|
||||
onClick={() => onReset('know')}
|
||||
/>
|
||||
<ProgressStatButton
|
||||
icon={<Sparkles className="mr-1 h-4" />}
|
||||
label="Learned"
|
||||
count={didNotKnowCount}
|
||||
isDisabled={didNotKnowCount === 0}
|
||||
onClick={() => onReset('dontKnow')}
|
||||
/>
|
||||
<ProgressStatButton
|
||||
icon={<SkipForward className="mr-1 h-4" />}
|
||||
label="Skipped"
|
||||
count={skippedCount}
|
||||
isDisabled={skippedCount === 0}
|
||||
onClick={() => onReset('skip')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 mb-4 sm:mb-0 text-sm">
|
||||
<button
|
||||
onClick={() => onReset('reset')}
|
||||
className="flex items-center gap-0.5 text-red-700 hover:text-black text-sm sm:text-base"
|
||||
>
|
||||
<RefreshCcw className="mr-1 h-4" />
|
||||
Restart Asking
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/components/Questions/QuestionLoader.tsx
Normal file
12
src/components/Questions/QuestionLoader.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import {Spinner} from "../ReactIcons/Spinner";
|
||||
|
||||
export function QuestionLoader() {
|
||||
return (
|
||||
<div className="flex flex-grow flex-col items-center justify-center">
|
||||
<p className="text-xl font-medium text-gray-500 flex items-center gap-3.5">
|
||||
<Spinner isDualRing={false} innerFill='#6b7280' className="h-5 w-5" />
|
||||
Please wait ..
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
286
src/components/Questions/QuestionsList.tsx
Normal file
286
src/components/Questions/QuestionsList.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { QuestionsProgress } from './QuestionsProgress';
|
||||
import { CheckCircle, SkipForward, Sparkles } from 'lucide-react';
|
||||
import { QuestionCard } from './QuestionCard';
|
||||
import { QuestionLoader } from './QuestionLoader';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { QuestionType } from '../../lib/question-group';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { QuestionFinished } from './QuestionFinished';
|
||||
import { Confetti } from '../Confetti';
|
||||
|
||||
type UserQuestionProgress = {
|
||||
know: string[];
|
||||
dontKnow: string[];
|
||||
skip: string[];
|
||||
};
|
||||
|
||||
export type QuestionProgressType = keyof UserQuestionProgress;
|
||||
|
||||
type QuestionsListProps = {
|
||||
groupId: string;
|
||||
questions: QuestionType[];
|
||||
};
|
||||
|
||||
export function QuestionsList(props: QuestionsListProps) {
|
||||
const { questions: unshuffledQuestions, groupId } = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [questions, setQuestions] = useState<QuestionType[]>();
|
||||
const [pendingQuestions, setPendingQuestions] = useState<QuestionType[]>([]);
|
||||
|
||||
const [userProgress, setUserProgress] = useState<UserQuestionProgress>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
async function fetchUserProgress(): Promise<
|
||||
UserQuestionProgress | undefined
|
||||
> {
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<UserQuestionProgress>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-get-user-question-progress/${groupId}`
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Error fetching user progress');
|
||||
return;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function loadQuestions() {
|
||||
const userProgress = await fetchUserProgress();
|
||||
setUserProgress(userProgress);
|
||||
|
||||
const knownQuestions = userProgress?.know || [];
|
||||
const didNotKnowQuestions = userProgress?.dontKnow || [];
|
||||
const skipQuestions = userProgress?.skip || [];
|
||||
|
||||
const pendingQuestions = unshuffledQuestions.filter((question) => {
|
||||
return (
|
||||
!knownQuestions.includes(question.id) &&
|
||||
!didNotKnowQuestions.includes(question.id) &&
|
||||
!skipQuestions.includes(question.id)
|
||||
);
|
||||
});
|
||||
|
||||
// Shuffle and set pending questions
|
||||
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
|
||||
setQuestions(unshuffledQuestions);
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function resetProgress(type: QuestionProgressType | 'reset' = 'reset') {
|
||||
let knownQuestions = userProgress?.know || [];
|
||||
let didNotKnowQuestions = userProgress?.dontKnow || [];
|
||||
let skipQuestions = userProgress?.skip || [];
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
if (type === 'know') {
|
||||
knownQuestions = [];
|
||||
} else if (type === 'dontKnow') {
|
||||
didNotKnowQuestions = [];
|
||||
} else if (type === 'skip') {
|
||||
skipQuestions = [];
|
||||
} else if (type === 'reset') {
|
||||
knownQuestions = [];
|
||||
didNotKnowQuestions = [];
|
||||
skipQuestions = [];
|
||||
}
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response, error } = await httpPut<UserQuestionProgress>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-reset-question-progress/${groupId}`,
|
||||
{
|
||||
status: type,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Error resetting progress');
|
||||
return;
|
||||
}
|
||||
|
||||
knownQuestions = response?.know || [];
|
||||
didNotKnowQuestions = response?.dontKnow || [];
|
||||
skipQuestions = response?.skip || [];
|
||||
}
|
||||
|
||||
const pendingQuestions = unshuffledQuestions.filter((question) => {
|
||||
return (
|
||||
!knownQuestions.includes(question.id) &&
|
||||
!didNotKnowQuestions.includes(question.id) &&
|
||||
!skipQuestions.includes(question.id)
|
||||
);
|
||||
});
|
||||
|
||||
setUserProgress({
|
||||
know: knownQuestions,
|
||||
dontKnow: didNotKnowQuestions,
|
||||
skip: skipQuestions,
|
||||
});
|
||||
|
||||
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
async function updateQuestionStatus(
|
||||
status: QuestionProgressType,
|
||||
questionId: string
|
||||
) {
|
||||
setIsLoading(true);
|
||||
let newProgress = userProgress || { know: [], dontKnow: [], skip: [] };
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
if (status === 'know') {
|
||||
newProgress.know.push(questionId);
|
||||
} else if (status == 'dontKnow') {
|
||||
newProgress.dontKnow.push(questionId);
|
||||
} else if (status == 'skip') {
|
||||
newProgress.skip.push(questionId);
|
||||
}
|
||||
} else {
|
||||
const { response, error } = await httpPut<UserQuestionProgress>(
|
||||
`${
|
||||
import.meta.env.PUBLIC_API_URL
|
||||
}/v1-update-question-status/${groupId}`,
|
||||
{
|
||||
status,
|
||||
questionId,
|
||||
questionGroupId: groupId,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
toast.error(error?.message || 'Error marking question status');
|
||||
return;
|
||||
}
|
||||
|
||||
newProgress = response;
|
||||
}
|
||||
|
||||
const updatedQuestionList = pendingQuestions.filter(
|
||||
(q) => q.id !== questionId
|
||||
);
|
||||
|
||||
setUserProgress(newProgress);
|
||||
setPendingQuestions(updatedQuestionList);
|
||||
setIsLoading(false);
|
||||
|
||||
if (updatedQuestionList.length === 0) {
|
||||
setShowConfetti(true);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions().then(() => null);
|
||||
}, [unshuffledQuestions]);
|
||||
|
||||
const knowCount = userProgress?.know.length || 0;
|
||||
const dontKnowCount = userProgress?.dontKnow.length || 0;
|
||||
const skipCount = userProgress?.skip.length || 0;
|
||||
const hasProgress = knowCount > 0 || dontKnowCount > 0 || skipCount > 0;
|
||||
|
||||
const currQuestion = pendingQuestions[0];
|
||||
const hasFinished = !isLoading && hasProgress && !currQuestion;
|
||||
|
||||
return (
|
||||
<div className="mb-0 sm:mb-40 gap-3 text-center">
|
||||
<QuestionsProgress
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
skippedCount={skipCount}
|
||||
totalCount={unshuffledQuestions?.length || questions?.length}
|
||||
isLoading={isLoading}
|
||||
showLoginAlert={!isLoggedIn() && hasProgress}
|
||||
onResetClick={() => {
|
||||
resetProgress('reset').finally(() => null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{showConfetti && containerRef.current && (
|
||||
<Confetti
|
||||
pieces={100}
|
||||
element={containerRef.current}
|
||||
onDone={() => {
|
||||
setShowConfetti(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mb-4 flex min-h-[250px] w-full overflow-hidden rounded-lg border border-gray-300 bg-white sm:min-h-[400px]"
|
||||
>
|
||||
{hasFinished && (
|
||||
<QuestionFinished
|
||||
totalCount={unshuffledQuestions?.length || questions?.length || 0}
|
||||
knowCount={knowCount}
|
||||
didNotKnowCount={dontKnowCount}
|
||||
skippedCount={skipCount}
|
||||
onReset={(type: QuestionProgressType | 'reset') => {
|
||||
resetProgress(type).finally(() => null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && currQuestion && <QuestionCard question={currQuestion} />}
|
||||
{isLoading && <QuestionLoader />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex flex-col gap-1 sm:gap-3 transition-opacity duration-300 sm:flex-row ${
|
||||
hasFinished ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
disabled={isLoading || !currQuestion}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
updateQuestionStatus('know', currQuestion.id).finally(() => null);
|
||||
}}
|
||||
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-4 text-current" />
|
||||
Already Know that
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateQuestionStatus('dontKnow', currQuestion.id).finally(
|
||||
() => null
|
||||
);
|
||||
}}
|
||||
disabled={isLoading || !currQuestion}
|
||||
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<Sparkles className="mr-1 h-4 text-current" />
|
||||
Didn't Know that
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateQuestionStatus('skip', currQuestion.id).finally(() => null);
|
||||
}}
|
||||
disabled={isLoading || !currQuestion}
|
||||
data-next-question="skip"
|
||||
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-red-600 text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<SkipForward className="mr-1 h-4" />
|
||||
Skip Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
src/components/Questions/QuestionsProgress.tsx
Normal file
101
src/components/Questions/QuestionsProgress.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { CheckCircle, RotateCcw, SkipForward, Sparkles } from 'lucide-react';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
type QuestionsProgressProps = {
|
||||
isLoading?: boolean;
|
||||
showLoginAlert?: boolean;
|
||||
knowCount?: number;
|
||||
didNotKnowCount?: number;
|
||||
totalCount?: number;
|
||||
skippedCount?: number;
|
||||
onResetClick?: () => void;
|
||||
};
|
||||
|
||||
export function QuestionsProgress(props: QuestionsProgressProps) {
|
||||
const {
|
||||
showLoginAlert,
|
||||
isLoading = false,
|
||||
knowCount = 0,
|
||||
didNotKnowCount = 0,
|
||||
totalCount = 0,
|
||||
skippedCount = 0,
|
||||
onResetClick = () => null,
|
||||
} = props;
|
||||
|
||||
const totalSolved = knowCount + didNotKnowCount + skippedCount;
|
||||
const donePercentage = (totalSolved / totalCount) * 100;
|
||||
|
||||
return (
|
||||
<div className="mb-3 sm:mb-5 overflow-hidden rounded-lg border border-gray-300 bg-white p-4 sm:p-6">
|
||||
<div className="mb-3 flex items-center text-gray-600">
|
||||
<div className="relative w-full flex-1 rounded-xl bg-gray-200 p-1">
|
||||
<div
|
||||
className="duration-400 absolute bottom-0 left-0 top-0 rounded-xl bg-slate-800 transition-[width]"
|
||||
style={{
|
||||
width: `${donePercentage}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-3 text-sm">
|
||||
{totalSolved} / {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative -left-1 flex flex-col gap-2 text-sm text-black sm:flex-row sm:gap-3">
|
||||
<span className="flex items-center">
|
||||
<CheckCircle className="mr-1 h-4" />
|
||||
<span>Knew</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{knowCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center">
|
||||
<Sparkles className="mr-1 h-4" />
|
||||
<span>Learnt</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{didNotKnowCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="flex items-center">
|
||||
<SkipForward className="mr-1 h-4" />
|
||||
<span>Skipped</span>
|
||||
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
|
||||
<span className="tabular-nums">{skippedCount}</span>{' '}
|
||||
<span className="hidden lg:inline">Questions</span>
|
||||
<span className="inline sm:hidden">Questions</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
disabled={isLoading}
|
||||
onClick={onResetClick}
|
||||
className="flex items-center text-red-600 transition-opacity duration-300 hover:text-red-900 disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4" />
|
||||
Reset
|
||||
<span className='inline lg:hidden'>Progress</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showLoginAlert && (
|
||||
<p className="-mx-6 mt-6 -mb-6 border-t bg-yellow-100 py-3 text-sm text-yellow-900">
|
||||
You progress is not saved. Please{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
showLoginPopup();
|
||||
}}
|
||||
className="underline-offset-3 font-medium underline hover:text-black"
|
||||
>
|
||||
login to save your progress.
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/components/ReactIcons/AddUserIcon.tsx
Normal file
27
src/components/ReactIcons/AddUserIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
type CheckIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function AddUserIcon(props: CheckIconProps) {
|
||||
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`relative ${additionalClasses}`}
|
||||
>
|
||||
<path d="M14 19a6 6 0 0 0-12 0" />
|
||||
<circle cx="8" cy="9" r="4" />
|
||||
<line x1="19" x2="19" y1="8" y2="14" />
|
||||
<line x1="22" x2="16" y1="11" y2="11" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/ReactIcons/AddedUserIcon.tsx
Normal file
26
src/components/ReactIcons/AddedUserIcon.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
type CheckIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function AddedUserIcon(props: CheckIconProps) {
|
||||
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={`relative ${additionalClasses}`}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M14 19a6 6 0 0 0-12 0" />
|
||||
<circle cx="8" cy="9" r="4" />
|
||||
<polyline points="16 11 18 13 22 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,10 @@ export function CheckIcon(props: CheckIconProps) {
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`relative ${additionalClasses}]`}
|
||||
className={`relative ${additionalClasses}`}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user