Compare commits
385 Commits
feat/user-
...
fix/authen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce5545b60 | ||
|
|
19f5885e62 | ||
|
|
5c771c806d | ||
|
|
81a14e90eb | ||
|
|
51dd58f7ed | ||
|
|
28a0fca90d | ||
|
|
be8495a60a | ||
|
|
cf5ac18aa1 | ||
|
|
0c7bc0e330 | ||
|
|
94ba9e7451 | ||
|
|
0de6ed6028 | ||
|
|
ef0c9f3db2 | ||
|
|
468e92bac1 | ||
|
|
733e9cb5af | ||
|
|
714ca8c49f | ||
|
|
63c3850f0e | ||
|
|
e2f7abe69a | ||
|
|
780402afd6 | ||
|
|
2b74f70ef9 | ||
|
|
1eab06f1f5 | ||
|
|
d2b7704370 | ||
|
|
a5d5f63677 | ||
|
|
dfc7821a44 | ||
|
|
1cee0b36dc | ||
|
|
0d1f916535 | ||
|
|
ce0f2a4ee4 | ||
|
|
bf89b013d1 | ||
|
|
5bcb3e282d | ||
|
|
747652c0f3 | ||
|
|
ed0e376d46 | ||
|
|
ef3e4fc3f3 | ||
|
|
932896c3af | ||
|
|
1539c6ccaf | ||
|
|
84aa35cdec | ||
|
|
b6a852b29b | ||
|
|
2d2f670153 | ||
|
|
5cf7aa340f | ||
|
|
601d21ca9d | ||
|
|
a5527dd872 | ||
|
|
4d6d943b4e | ||
|
|
85214da400 | ||
|
|
46eb27a810 | ||
|
|
e47bd63cc9 | ||
|
|
d314f3d8c1 | ||
|
|
52fdd8f07d | ||
|
|
22f59c66f0 | ||
|
|
4a862241d3 | ||
|
|
b1fdc7ff49 | ||
|
|
445bdabde5 | ||
|
|
c46b4220a7 | ||
|
|
cdcdfc4973 | ||
|
|
d4b4b3c55c | ||
|
|
2c0ebe4209 | ||
|
|
c51438142c | ||
|
|
d5a47b79db | ||
|
|
ca2a75537e | ||
|
|
f62faf214c | ||
|
|
00b9630669 | ||
|
|
49ba524c15 | ||
|
|
d4436e8a8f | ||
|
|
e0b3209dc4 | ||
|
|
cf5dd19652 | ||
|
|
16680e2629 | ||
|
|
b9b12333cb | ||
|
|
8a9bb60211 | ||
|
|
2c6bef62b2 | ||
|
|
efb7e13f7d | ||
|
|
b34c7eff65 | ||
|
|
15c43fda5d | ||
|
|
b38f34a722 | ||
|
|
f1780fabda | ||
|
|
5362a64c29 | ||
|
|
720809f139 | ||
|
|
5b03601aa2 | ||
|
|
90df308751 | ||
|
|
3c0545e54f | ||
|
|
4eb145dff4 | ||
|
|
966d5fedb5 | ||
|
|
243778cf11 | ||
|
|
9c9c59911b | ||
|
|
7a93301b5b | ||
|
|
aa056c1da8 | ||
|
|
13d1879977 | ||
|
|
aca3163ba9 | ||
|
|
5e80d9d4d8 | ||
|
|
0fc28c482a | ||
|
|
837d2ac782 | ||
|
|
68c62bacc2 | ||
|
|
720438e619 | ||
|
|
3afab1aa70 | ||
|
|
f40585f992 | ||
|
|
9232d03e24 | ||
|
|
01cb4b5131 | ||
|
|
50f02b504a | ||
|
|
2d12bffe46 | ||
|
|
3b1762cd91 | ||
|
|
d9be6e3c8b | ||
|
|
b65328ebc9 | ||
|
|
5da5924b6c | ||
|
|
b35a169315 | ||
|
|
9d05c64f50 | ||
|
|
e94296cdd4 | ||
|
|
7a4796508d | ||
|
|
e0f5d6f436 | ||
|
|
d103bc629c | ||
|
|
cb9943191e | ||
|
|
eaa567dfe0 | ||
|
|
277713e16b | ||
|
|
5ed49b965c | ||
|
|
a27aaf6e2d | ||
|
|
be02cc59ea | ||
|
|
068847af08 | ||
|
|
c6c91ef8fe | ||
|
|
8fb3e7983b | ||
|
|
80ec1a1c4b | ||
|
|
76d1ca1333 | ||
|
|
40357f7956 | ||
|
|
581f4a76a4 | ||
|
|
ef1a3031c4 | ||
|
|
3774f3c5ec | ||
|
|
b11da48f41 | ||
|
|
5edda5654c | ||
|
|
505077a545 | ||
|
|
9f4967929f | ||
|
|
27cb89494f | ||
|
|
ec556915e4 | ||
|
|
c61e44119d | ||
|
|
6f46d723bc | ||
|
|
ee6e3e4029 | ||
|
|
6e9fe97e5c | ||
|
|
13af03c930 | ||
|
|
78692ff13f | ||
|
|
54d7388b09 | ||
|
|
b609c43055 | ||
|
|
d83fe1279b | ||
|
|
fb3cb85c14 | ||
|
|
82dbca95fb | ||
|
|
7e702ee385 | ||
|
|
08fbb730ab | ||
|
|
cd80338fa6 | ||
|
|
fa33d0c339 | ||
|
|
8ec9a6e675 | ||
|
|
16853df928 | ||
|
|
c15d139d54 | ||
|
|
4e5cc5bd35 | ||
|
|
a36bca2f42 | ||
|
|
10b688049d | ||
|
|
0db92f6418 | ||
|
|
dccaa66ed4 | ||
|
|
3deee4dfc3 | ||
|
|
980e243124 | ||
|
|
044046e044 | ||
|
|
793764c3a3 | ||
|
|
abc8a97676 | ||
|
|
79355cd876 | ||
|
|
2809b81920 | ||
|
|
204a9577cd | ||
|
|
577e724aa7 | ||
|
|
14a1544ed4 | ||
|
|
14ea7ba0ad | ||
|
|
5e7ec4f8d8 | ||
|
|
417badc6ea | ||
|
|
0558957673 | ||
|
|
7f6a42a0c5 | ||
|
|
cc258b7612 | ||
|
|
7da244fe10 | ||
|
|
cf78628c0c | ||
|
|
498e03720f | ||
|
|
5c69b05470 | ||
|
|
309cf3d6d9 | ||
|
|
4f3b891e45 | ||
|
|
47f548a0e4 | ||
|
|
a988ecc4ab | ||
|
|
c723070057 | ||
|
|
3a0e588530 | ||
|
|
d46cf26812 | ||
|
|
b06e82de5f | ||
|
|
d65ecac777 | ||
|
|
c46d962803 | ||
|
|
bd4e7ea3d0 | ||
|
|
252b083a48 | ||
|
|
abbeb717d1 | ||
|
|
485ca9dd8f | ||
|
|
c3315fb41e | ||
|
|
6ed436674f | ||
|
|
76c6c4dc1f | ||
|
|
cb56e85651 | ||
|
|
dcf740e275 | ||
|
|
16662ed699 | ||
|
|
6f9fe361ae | ||
|
|
036b34c6f3 | ||
|
|
93c2043f23 | ||
|
|
d2da3c8621 | ||
|
|
4aa8f15c07 | ||
|
|
ceb4c3b95d | ||
|
|
7ec5e30b51 | ||
|
|
e5e0a7c8c5 | ||
|
|
90f3ffe270 | ||
|
|
ce47a7433e | ||
|
|
21b8358683 | ||
|
|
e1751b105f | ||
|
|
e43bea7c40 | ||
|
|
5fa669aec2 | ||
|
|
4b8f868b2b | ||
|
|
a0743a8272 | ||
|
|
2cae13c090 | ||
|
|
0bf287f1d6 | ||
|
|
d7d819b4b3 | ||
|
|
29cff6a6f8 | ||
|
|
044df81b7a | ||
|
|
3151ee5021 | ||
|
|
e6ce9f40ee | ||
|
|
3b5e3c44f9 | ||
|
|
c286e0a6f8 | ||
|
|
3bebe0c1de | ||
|
|
9845fe624a | ||
|
|
4b2b2ebe8c | ||
|
|
82c2aaacc3 | ||
|
|
6d1edb76c7 | ||
|
|
5d57d5baaf | ||
|
|
d31d626c61 | ||
|
|
71bf34e683 | ||
|
|
93a91b1d9b | ||
|
|
18c8bd14b2 | ||
|
|
e34695e334 | ||
|
|
8310671123 | ||
|
|
d45c8f9cb2 | ||
|
|
573263ed74 | ||
|
|
f27aa58ac3 | ||
|
|
518cf4ce73 | ||
|
|
7bde0b3f44 | ||
|
|
4b6dcb3a37 | ||
|
|
c50200bfe7 | ||
|
|
5ffb9fad9f | ||
|
|
dd7d312aa1 | ||
|
|
81447f6b43 | ||
|
|
fe711f498d | ||
|
|
c65f12fcb8 | ||
|
|
cab075bf5b | ||
|
|
685021493c | ||
|
|
482cf64bf5 | ||
|
|
9051e22476 | ||
|
|
1b538b399f | ||
|
|
05673087c5 | ||
|
|
5256df9c07 | ||
|
|
ddf8884501 | ||
|
|
05492b60ee | ||
|
|
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 |
@@ -1,2 +1,3 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_API_URL=https://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
PUBLIC_EDITOR_APP_URL=https://draw.roadmap.sh
|
||||
@@ -14,24 +14,12 @@ body:
|
||||
placeholder: e.g. Roadmap to learn Data Science
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: Is this roadmap prepared by you or someone else?
|
||||
options:
|
||||
- I prepared this roadmap
|
||||
- I found this roadmap online (please provide a link below)
|
||||
- type: textarea
|
||||
id: roadmap-description
|
||||
attributes:
|
||||
label: Roadmap 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).
|
||||
label: Roadmap Link
|
||||
description: Please create the roadmap [using our roadmap editor](https://twitter.com/kamrify/status/1708293162693767426) and submit the roadmap link.
|
||||
placeholder: |
|
||||
- Item 1
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
- Item 2
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
https://roadmap.sh/xyz
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
name: Deployment to GH Pages
|
||||
name: App Deployment
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
PUBLIC_EDITOR_APP_URL: "https://draw.roadmap.sh"
|
||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PAT: ${{ secrets.PAT }}
|
||||
CI: true
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18
|
||||
- run: git config --global url."https://${{ secrets.PAT }}@github.com/".insteadOf ssh://git@github.com/
|
||||
- name: Prepare Draw Repository
|
||||
run: |
|
||||
git clone https://${{ secrets.GH_PAT }}@github.com/roadmapsh/web-draw.git .temp/web-draw --depth 1
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
@@ -27,6 +29,7 @@ jobs:
|
||||
pnpm install
|
||||
- name: Generate meta and build
|
||||
run: |
|
||||
npm run generate-renderer
|
||||
npm run build
|
||||
touch ./dist/.nojekyll
|
||||
echo 'roadmap.sh' > ./dist/CNAME
|
||||
2
.github/workflows/update-deps.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
upgrade-deps:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
4
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
.idea
|
||||
.temp
|
||||
|
||||
# build output
|
||||
dist/
|
||||
@@ -27,3 +28,6 @@ pnpm-debug.log*
|
||||
/playwright/.cache/
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
/editor/*
|
||||
!/editor/readonly-editor.tsx
|
||||
|
||||
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
auto-install-peers=true
|
||||
strict-peer-dependencies=false
|
||||
@@ -13,6 +13,6 @@ module.exports = {
|
||||
],
|
||||
plugins: [
|
||||
require.resolve('prettier-plugin-astro'),
|
||||
require('prettier-plugin-tailwindcss'),
|
||||
'prettier-plugin-tailwindcss',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
14
editor/readonly-editor.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function ReadonlyEditor(props: any) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
|
||||
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
|
||||
<p className="mb-4">
|
||||
Renderer is a private component. If you are a collaborator and have
|
||||
access to it. Run the following command:
|
||||
</p>
|
||||
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
|
||||
npm run generate-renderer
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
package.json
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "roadmap.sh",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev": "astro dev --port 3000",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
@@ -16,40 +16,54 @@
|
||||
"roadmap-links": "node scripts/roadmap-links.cjs",
|
||||
"roadmap-dirs": "node scripts/roadmap-dirs.cjs",
|
||||
"roadmap-content": "node scripts/roadmap-content.cjs",
|
||||
"generate-renderer": "sh scripts/generate-renderer.sh",
|
||||
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
||||
"best-practice-content": "node scripts/best-practice-content.cjs",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/preact": "^2.2.1",
|
||||
"@astrojs/sitemap": "^1.3.3",
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.5.0",
|
||||
"astro": "^2.6.6",
|
||||
"astro-compress": "^1.1.47",
|
||||
"jose": "^4.14.4",
|
||||
"@astrojs/react": "^3.0.9",
|
||||
"@astrojs/sitemap": "^3.0.5",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fingerprintjs/fingerprintjs": "^4.2.1",
|
||||
"@nanostores/react": "^0.7.1",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"astro": "^4.2.1",
|
||||
"astro-compress": "^2.2.8",
|
||||
"clsx": "^2.1.0",
|
||||
"dracula-prism": "^2.1.13",
|
||||
"jose": "^5.2.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.9.2",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"preact": "^10.15.1",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"lucide-react": "^0.300.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nanostores": "^0.9.5",
|
||||
"node-html-parser": "^6.1.12",
|
||||
"npm-check-updates": "^16.14.12",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.2.0",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"reactflow": "^11.10.2",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"slugify": "^1.6.6",
|
||||
"tailwindcss": "^3.3.2"
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.35.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"@playwright/test": "^1.41.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^5.0.0",
|
||||
"gh-pages": "^6.1.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.3.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-astro": "^0.10.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
"markdown-it": "^14.0.0",
|
||||
"openai": "^4.25.0",
|
||||
"prettier": "^3.2.4",
|
||||
"prettier-plugin-astro": "^0.12.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11"
|
||||
}
|
||||
}
|
||||
|
||||
4655
pnpm-lock.yaml
generated
BIN
public/authors/fernando.jpeg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/guides/backend-languages/back-vs-front.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/guides/backend-languages/backend-roadmap-part.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
public/guides/backend-languages/javascript-interest.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
public/guides/backend-languages/pypl-go-index.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
3
public/images/hackernews.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 32 32">
|
||||
<path fill="#94a3b8" d="M5 5v22h22V5zm2 2h18v18H7zm4.5 4l3.5 6v5h2v-5l3.5-6h-2L16 15.281L13.5 11z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 203 B |
BIN
public/images/partners/nginx.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/images/partners/spring-tile.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/images/partners/zilliz.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
1
public/images/reddit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 1024 1024"><path fill="#94a3b8" d="M288 568a56 56 0 1 0 112 0a56 56 0 1 0-112 0zm338.7 119.7c-23.1 18.2-68.9 37.8-114.7 37.8s-91.6-19.6-114.7-37.8c-14.4-11.3-35.3-8.9-46.7 5.5s-8.9 35.3 5.5 46.7C396.3 771.6 457.5 792 512 792s115.7-20.4 155.9-52.1a33.25 33.25 0 1 0-41.2-52.2zM960 456c0-61.9-50.1-112-112-112c-42.1 0-78.7 23.2-97.9 57.6c-57.6-31.5-127.7-51.8-204.1-56.5L612.9 195l127.9 36.9c11.5 32.6 42.6 56.1 79.2 56.1c46.4 0 84-37.6 84-84s-37.6-84-84-84c-32 0-59.8 17.9-74 44.2L603.5 123a33.2 33.2 0 0 0-39.6 18.4l-90.8 203.9c-74.5 5.2-142.9 25.4-199.2 56.2A111.94 111.94 0 0 0 176 344c-61.9 0-112 50.1-112 112c0 45.8 27.5 85.1 66.8 102.5c-7.1 21-10.8 43-10.8 65.5c0 154.6 175.5 280 392 280s392-125.4 392-280c0-22.6-3.8-44.5-10.8-65.5C932.5 541.1 960 501.8 960 456zM820 172.5a31.5 31.5 0 1 1 0 63a31.5 31.5 0 0 1 0-63zM120 456c0-30.9 25.1-56 56-56a56 56 0 0 1 50.6 32.1c-29.3 22.2-53.5 47.8-71.5 75.9a56.23 56.23 0 0 1-35.1-52zm392 381.5c-179.8 0-325.5-95.6-325.5-213.5S332.2 410.5 512 410.5S837.5 506.1 837.5 624S691.8 837.5 512 837.5zM868.8 508c-17.9-28.1-42.2-53.7-71.5-75.9c9-18.9 28.3-32.1 50.6-32.1c30.9 0 56 25.1 56 56c.1 23.5-14.5 43.7-35.1 52zM624 568a56 56 0 1 0 112 0a56 56 0 1 0-112 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/images/roadmap-editor.jpeg
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
public/images/team-promo/contact.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/images/team-promo/documentation.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
public/images/team-promo/growth-plans.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
public/images/team-promo/hero-img.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/team-promo/hero.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/images/team-promo/invite-members.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
public/images/team-promo/many-roadmaps.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
public/images/team-promo/onboarding.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
public/images/team-promo/our-roadmaps.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/images/team-promo/progress-tracking.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
public/images/team-promo/roadmap-editor.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/images/team-promo/sharing-settings.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
public/images/team-promo/skill-gap.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/images/team-promo/team-dashboard.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
public/images/team-promo/team-insights.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
public/images/team-promo/update-progress.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
0
public/manifest/apple-touch-icon.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
0
public/manifest/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
0
public/manifest/icon152.png
Executable file → Normal file
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
0
public/manifest/icon16.png
Executable file → Normal file
|
Before Width: | Height: | Size: 123 B After Width: | Height: | Size: 123 B |
0
public/manifest/icon196.png
Executable file → Normal file
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
0
public/manifest/icon32.png
Executable file → Normal file
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
BIN
public/pdfs/roadmaps/ai-data-scientist.pdf
Normal file
BIN
public/pdfs/roadmaps/aws.pdf
Normal file
BIN
public/pdfs/roadmaps/game-developer.pdf
Normal file
BIN
public/pdfs/roadmaps/mlops.pdf
Normal file
BIN
public/pdfs/roadmaps/rust.pdf
Normal file
BIN
public/pdfs/roadmaps/server-side-game-developer.pdf
Normal file
BIN
public/pdfs/roadmaps/technical-writer.pdf
Normal file
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 561 KiB |
BIN
public/roadmaps/aws.png
Normal file
|
After Width: | Height: | Size: 636 KiB |
BIN
public/roadmaps/game-developer.png
Normal file
|
After Width: | Height: | Size: 614 KiB |
BIN
public/roadmaps/mlops.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
public/roadmaps/rust.png
Normal file
|
After Width: | Height: | Size: 599 KiB |
BIN
public/roadmaps/technical-writer.png
Normal file
|
After Width: | Height: | Size: 522 KiB |
19
readme.md
@@ -9,8 +9,8 @@
|
||||
<a href="https://roadmap.sh/best-practices">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
|
||||
</a>
|
||||
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
<a href="https://roadmap.sh/questions">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Questions-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
Roadmaps are now interactive, you can click the nodes to read more about the topics.
|
||||
|
||||
### [View all Roadmaps](https://roadmap.sh)
|
||||
### [View all Roadmaps](https://roadmap.sh) · [Best Practices](https://roadmap.sh/best-practices) · [Questions](https://roadmap.sh/questions)
|
||||
|
||||

|
||||
|
||||
@@ -35,8 +35,11 @@ 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)
|
||||
- [Game Developer Roadmap](https://roadmap.sh/game-developer) / [Server Side Game Developer](https://roadmap.sh/server-side-game-developer)
|
||||
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
|
||||
- [JavaScript Roadmap](https://roadmap.sh/javascript)
|
||||
- [TypeScript Roadmap](https://roadmap.sh/typescript)
|
||||
@@ -49,8 +52,8 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [GraphQL Roadmap](https://roadmap.sh/graphql)
|
||||
- [Android Roadmap](https://roadmap.sh/android)
|
||||
- [Flutter Roadmap](https://roadmap.sh/flutter)
|
||||
- [Python Roadmap](https://roadmap.sh/python)
|
||||
- [Go Roadmap](https://roadmap.sh/golang)
|
||||
- [Rust Roadmap](https://roadmap.sh/rust)
|
||||
- [Java Roadmap](https://roadmap.sh/java)
|
||||
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)
|
||||
- [Design System Roadmap](https://roadmap.sh/design-system)
|
||||
@@ -65,14 +68,20 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [UX Design Roadmap](https://roadmap.sh/ux-design)
|
||||
- [Docker Roadmap](https://roadmap.sh/docker)
|
||||
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
|
||||
- [Technical Writer Roadmap](https://roadmap.sh/technical-writer)
|
||||
|
||||
We have also added a new form of visual content covering best practices:
|
||||
There are also interactive best practices:
|
||||
|
||||
- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review)
|
||||
- [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance)
|
||||
- [API Security Best Practices](https://roadmap.sh/best-practices/api-security)
|
||||
- [AWS Best Practices](https://roadmap.sh/best-practices/aws)
|
||||
|
||||
..and questions to help you test, rate and improve your knowledge
|
||||
|
||||
- [JavaScript Questions](https://roadmap.sh/questions/javascript)
|
||||
- [React Questions](https://roadmap.sh/questions/react)
|
||||
|
||||

|
||||
|
||||
## Share with the community
|
||||
|
||||
14
renderer/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function Renderer(props: any) {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 top-0 z-[9999] border bg-white p-5 text-black">
|
||||
<h2 className="mb-2 text-xl font-semibold">Private Component</h2>
|
||||
<p className="mb-4">
|
||||
Renderer is a private component. If you are a collaborator and have
|
||||
access to it. Run the following command:
|
||||
</p>
|
||||
<code className="mt-5 rounded-md bg-gray-800 p-2 text-white">
|
||||
npm run generate-renderer
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
renderer/renderer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function renderFlowJSON(data: any, options?: any) {
|
||||
console.warn("renderFlowJSON is not implemented");
|
||||
console.warn("run the following command to generate the renderer:");
|
||||
console.warn("> npm run generate-renderer");
|
||||
}
|
||||
32
scripts/generate-renderer.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# ignore cloning if .temp/web-draw already exists
|
||||
if [ ! -d ".temp/web-draw" ]; then
|
||||
mkdir -p .temp
|
||||
git clone git@github.com:roadmapsh/web-draw.git .temp/web-draw
|
||||
fi
|
||||
|
||||
rm -rf editor
|
||||
mkdir editor
|
||||
|
||||
# copy the files at /src/editor/* to /editor
|
||||
# while replacing any existing files
|
||||
cp -rf .temp/web-draw/src/editor/* editor
|
||||
|
||||
# Add @ts-nocheck to the top of each ts and tsx file
|
||||
# so that the typescript compiler doesn't complain
|
||||
# about the missing types
|
||||
find editor -type f \( -name "*.ts" -o -name "*.tsx" \) -print0 | while IFS= read -r -d '' file; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "// @ts-nocheck" > temp
|
||||
cat "$file" >> temp
|
||||
mv temp "$file"
|
||||
echo "Added @ts-nocheck to $file"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
# ignore the worktree changes for the editor directory
|
||||
git update-index --assume-unchanged editor/readonly-editor.tsx
|
||||
@@ -19,13 +19,12 @@ if (!allowedRoadmapIds.includes(roadmapId)) {
|
||||
}
|
||||
|
||||
const ROADMAP_CONTENT_DIR = path.join(ALL_ROADMAPS_DIR, roadmapId, 'content');
|
||||
const { Configuration, OpenAIApi } = require('openai');
|
||||
const configuration = new Configuration({
|
||||
const OpenAI = require('openai');
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: OPEN_AI_API_KEY,
|
||||
});
|
||||
|
||||
const openai = new OpenAIApi(configuration);
|
||||
|
||||
function getFilesInFolder(folderPath, fileList = {}) {
|
||||
const files = fs.readdirSync(folderPath);
|
||||
|
||||
@@ -60,16 +59,16 @@ function writeTopicContent(currTopicUrl) {
|
||||
|
||||
const roadmapTitle = roadmapId.replace(/-/g, ' ');
|
||||
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
|
||||
if (!childTopic) {
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief paragraph for that. Your output should be strictly markdown. Do not include anything other than the description in your output. I already know the benefits of each so do not add benefits in the output.`;
|
||||
}
|
||||
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai
|
||||
.createChatCompletion({
|
||||
openai.chat.completions
|
||||
.create({
|
||||
model: 'gpt-4',
|
||||
messages: [
|
||||
{
|
||||
@@ -79,7 +78,7 @@ function writeTopicContent(currTopicUrl) {
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
const article = response.data.choices[0].message.content;
|
||||
const article = response.choices[0].message.content;
|
||||
|
||||
resolve(article);
|
||||
})
|
||||
@@ -92,7 +91,7 @@ function writeTopicContent(currTopicUrl) {
|
||||
async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
(control) => control?.typeID === 'Label',
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
|
||||
if (!currTopicUrl) {
|
||||
@@ -138,15 +137,14 @@ async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
async function run() {
|
||||
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
|
||||
|
||||
const roadmapJson = require(path.join(
|
||||
ALL_ROADMAPS_DIR,
|
||||
`${roadmapId}/${roadmapId}`
|
||||
));
|
||||
const roadmapJson = require(
|
||||
path.join(ALL_ROADMAPS_DIR, `${roadmapId}/${roadmapId}`),
|
||||
);
|
||||
|
||||
const groups = roadmapJson?.mockup?.controls?.control?.filter(
|
||||
(control) =>
|
||||
control.typeID === '__group__' &&
|
||||
!control.properties?.controlName?.startsWith('ext_link')
|
||||
!control.properties?.controlName?.startsWith('ext_link'),
|
||||
);
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
|
||||
@@ -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,6 +1,8 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
|
||||
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
|
||||
import { Map } from 'lucide-react';
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
@@ -21,6 +23,27 @@ const sidebarLinks = [
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/friends',
|
||||
title: 'Friends',
|
||||
id: 'friends',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'users',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
id: 'roadmaps',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'users',
|
||||
classes: 'h-4 w-4',
|
||||
component: Map,
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
@@ -74,7 +97,7 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<AstroIcon icon={'users'} class={`h-4 w-4 mr-2`} />
|
||||
Teams
|
||||
Teams
|
||||
</a>
|
||||
</li>
|
||||
{
|
||||
@@ -89,10 +112,16 @@ const sidebarLinks = [
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.icon.component ? (
|
||||
<sidebarLink.icon.component
|
||||
className={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
) : (
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
)}
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
@@ -125,10 +154,16 @@ const sidebarLinks = [
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.icon.component ? (
|
||||
<sidebarLink.icon.component
|
||||
className={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
) : (
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
)}
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
@@ -138,6 +173,10 @@ const sidebarLinks = [
|
||||
<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>
|
||||
);
|
||||
@@ -149,7 +188,12 @@ const sidebarLinks = [
|
||||
}
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
|
||||
<div
|
||||
class:list={[
|
||||
'grow px-0 py-0 md:py-10',
|
||||
{ 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar },
|
||||
]}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,11 +21,11 @@ function ActivityCounter(props: ActivityCounterType) {
|
||||
const { text, count } = props;
|
||||
|
||||
return (
|
||||
<div 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,10 +1,21 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ActivityCounters } from './ActivityCounters';
|
||||
import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
type ProgressResponse = {
|
||||
updatedAt: string;
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
done: number;
|
||||
total: number;
|
||||
isCustomResource: boolean;
|
||||
};
|
||||
|
||||
export type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
@@ -13,24 +24,9 @@ export type ActivityResponse = {
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
roadmaps: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
total: number;
|
||||
skipped: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
bestPractices: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
roadmaps: ProgressResponse[];
|
||||
bestPractices: ProgressResponse[];
|
||||
customs: ProgressResponse[];
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
@@ -91,16 +87,16 @@ export function ActivityPage() {
|
||||
streak={activity?.streak || { count: 0 }}
|
||||
/>
|
||||
|
||||
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
{learningRoadmaps.length === 0 &&
|
||||
learningBestPractices.length === 0 && <EmptyActivity />}
|
||||
|
||||
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
|
||||
<>
|
||||
<h2 class="mb-3 text-xs uppercase text-gray-400">
|
||||
<h2 className="mb-3 text-xs uppercase text-gray-400">
|
||||
Continue Following
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
@@ -110,6 +106,8 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
key={roadmap.id}
|
||||
isCustomResource={roadmap.isCustomResource}
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
@@ -136,6 +134,8 @@ export function ActivityPage() {
|
||||
})
|
||||
.map((bestPractice) => (
|
||||
<ResourceProgress
|
||||
isCustomResource={bestPractice.isCustomResource}
|
||||
key={bestPractice.id}
|
||||
doneCount={bestPractice.done || 0}
|
||||
totalCount={bestPractice.total || 0}
|
||||
learningCount={bestPractice.learning || 0}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import { RoadmapIcon } from "../ReactIcons/RoadmapIcon";
|
||||
|
||||
export function EmptyActivity() {
|
||||
return (
|
||||
<div class="rounded-md">
|
||||
<div class="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"
|
||||
/>
|
||||
<h2 class="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<div className="rounded-md">
|
||||
<div className="flex flex-col items-center p-7 text-center">
|
||||
<RoadmapIcon className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10" />
|
||||
|
||||
<h2 className="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<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,8 +1,9 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
|
||||
import { useState } from 'react';
|
||||
import { getUser } from '../../lib/jwt';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
@@ -15,14 +16,17 @@ type ResourceProgressType = {
|
||||
skippedCount: number;
|
||||
onCleared?: () => void;
|
||||
showClearButton?: boolean;
|
||||
isCustomResource: boolean;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const { showClearButton = true } = props;
|
||||
const { showClearButton = true, isCustomResource } = props;
|
||||
const toast = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const userId = getUser()?.id;
|
||||
|
||||
const {
|
||||
updatedAt,
|
||||
resourceType,
|
||||
@@ -52,8 +56,8 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-favorite`);
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-${userId}-progress`);
|
||||
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
@@ -62,11 +66,15 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
}
|
||||
}
|
||||
|
||||
const url =
|
||||
let url =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}`
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
if (isCustomResource) {
|
||||
url = `/r?id=${resourceId}`;
|
||||
}
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
|
||||
@@ -112,6 +120,7 @@ export function ResourceProgress(props: ResourceProgressType) {
|
||||
<ProgressShareButton
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
isCustomResource={isCustomResource}
|
||||
className="text-xs font-normal"
|
||||
shareIconClassName="w-2.5 h-2.5 stroke-2"
|
||||
checkIconClassName="w-2.5 h-2.5"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../hooks/use-outside-click';
|
||||
import { OptionType, SearchSelector } from './SearchSelector';
|
||||
import { type OptionType, SearchSelector } from './SearchSelector';
|
||||
import type { PageType } from './CommandMenu/CommandMenu';
|
||||
import { CheckIcon } from './ReactIcons/CheckIcon';
|
||||
import { httpPut } from '../lib/http';
|
||||
@@ -65,15 +65,15 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
)?.title;
|
||||
|
||||
return (
|
||||
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
className="popup-body relative rounded-lg bg-white p-4 shadow"
|
||||
>
|
||||
{isLoading && (
|
||||
<>
|
||||
<div class="flex items-center justify-center gap-2 py-8">
|
||||
<div className="flex items-center justify-center gap-2 py-8">
|
||||
<Spinner isDualRing={false} className="h-4 w-4" />
|
||||
<h2 className="font-medium">Loading...</h2>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
{!isLoading && !error && selectedRoadmap && (
|
||||
<div className={'text-center'}>
|
||||
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
|
||||
<h3 class="mb-1.5 text-2xl font-medium">
|
||||
<h3 className="mb-1.5 text-2xl font-medium">
|
||||
{selectedRoadmapTitle} Added
|
||||
</h3>
|
||||
<p className="mb-4 text-sm leading-none text-gray-400">
|
||||
@@ -95,11 +95,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
to make changes to the roadmap.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
|
||||
>
|
||||
+ Add More
|
||||
</button>
|
||||
@@ -119,14 +119,14 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
|
||||
<h3 className="mb-1.5 text-2xl font-medium">Error</h3>
|
||||
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -135,7 +135,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
)}
|
||||
{!isLoading && !error && !selectedRoadmap && (
|
||||
<>
|
||||
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
|
||||
<h3 className="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
|
||||
<p className="mb-3 text-sm leading-none text-gray-400">
|
||||
Search and add a roadmap
|
||||
</p>
|
||||
@@ -156,11 +156,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
|
||||
placeholder={'Search for roadmap'}
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
15
src/components/AppChecklist.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PartyPopper } from 'lucide-react';
|
||||
|
||||
export function AppChecklist() {
|
||||
return (
|
||||
<div className="fixed bottom-6 right-3">
|
||||
<a
|
||||
href="/get-started"
|
||||
className="flex items-center gap-2 rounded-full border border-slate-900 bg-white py-2 pl-3 pr-4 text-sm font-medium hover:bg-zinc-200"
|
||||
>
|
||||
<PartyPopper className="relative -top-[2px] h-[20px] w-[20px] text-purple-600" />
|
||||
Welcome! Start here
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/AuthenticationFlow/AuthenticationForm.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
import { LinkedInButton } from './LinkedInButton';
|
||||
import { EmailLoginForm } from './EmailLoginForm';
|
||||
import { EmailSignupForm } from './EmailSignupForm';
|
||||
|
||||
type AuthenticationFormProps = {
|
||||
type?: 'login' | 'signup';
|
||||
};
|
||||
|
||||
export function AuthenticationForm(props: AuthenticationFormProps) {
|
||||
const { type = 'login' } = props;
|
||||
|
||||
const [isDisabled, setIsDisabled] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<GitHubButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
<GoogleButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
<LinkedInButton isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-2 py-6 text-sm text-slate-600">
|
||||
<div className="h-px w-full bg-slate-200" />
|
||||
OR
|
||||
<div className="h-px w-full bg-slate-200" />
|
||||
</div>
|
||||
|
||||
{type === 'login' ? (
|
||||
<EmailLoginForm isDisabled={isDisabled} setIsDisabled={setIsDisabled} />
|
||||
) : (
|
||||
<EmailSignupForm
|
||||
isDisabled={isDisabled}
|
||||
setIsDisabled={setIsDisabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
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<{}> = () => {
|
||||
type EmailLoginFormProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function EmailLoginForm(props: EmailLoginFormProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
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);
|
||||
setIsDisabled?.(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ token: string }>(
|
||||
@@ -21,7 +29,7 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Log the user in and reload the page
|
||||
@@ -29,6 +37,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();
|
||||
|
||||
@@ -38,12 +47,13 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
// @todo use proper types
|
||||
if ((error as any).type === 'user_not_verified') {
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
email,
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
setError(error?.message || 'Something went wrong. Please try again later.');
|
||||
};
|
||||
|
||||
@@ -76,7 +86,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"
|
||||
@@ -91,13 +101,11 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLoginForm;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
const EmailSignupForm: FunctionComponent = () => {
|
||||
type EmailSignupFormProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function EmailSignupForm(props: EmailSignupFormProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
@@ -10,10 +16,11 @@ 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);
|
||||
setIsDisabled?.(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ status: 'ok' }>(
|
||||
@@ -22,20 +29,21 @@ const EmailSignupForm: FunctionComponent = () => {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || response?.status !== 'ok') {
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
error?.message || 'Something went wrong. Please try again later.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
email,
|
||||
)}`;
|
||||
};
|
||||
|
||||
@@ -91,13 +99,11 @@ const EmailSignupForm: FunctionComponent = () => {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isDisabled}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSignupForm;
|
||||
}
|
||||
|
||||
@@ -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,20 +1,23 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import GitHubIcon from '../../icons/github.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
|
||||
type GitHubButtonProps = {};
|
||||
type GitHubButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
||||
const GITHUB_LAST_PAGE = 'githubLastPage';
|
||||
|
||||
export function GitHubButton(props: GitHubButtonProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GitHubIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -27,16 +30,18 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
|
||||
window.location.search
|
||||
}`
|
||||
}`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
const errMessage = error?.message || 'Something went wrong.';
|
||||
setError(errMessage);
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -57,43 +62,54 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
|
||||
const { response, error } = await httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`,
|
||||
);
|
||||
|
||||
if (error || !response?.loginUrl) {
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
error?.message || 'Something went wrong. Please try again later.',
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname,
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
|
||||
@@ -105,15 +121,15 @@ 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"
|
||||
disabled={isLoading}
|
||||
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 || isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="GitHub"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
||||
) : (
|
||||
<GitHubIcon className={'h-[18px] w-[18px]'} />
|
||||
)}
|
||||
Continue with GitHub
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
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';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { GoogleIcon } from '../ReactIcons/GoogleIcon.tsx';
|
||||
|
||||
type GoogleButtonProps = {};
|
||||
type GoogleButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
||||
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
||||
|
||||
export function GoogleButton(props: GoogleButtonProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GoogleIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -26,15 +30,17 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
|
||||
window.location.search
|
||||
}`
|
||||
}`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -55,29 +61,39 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -85,10 +101,11 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
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, pagePath);
|
||||
@@ -99,21 +116,22 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
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 || isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
||||
) : (
|
||||
<GoogleIcon className={'h-[18px] w-[18px]'} />
|
||||
)}
|
||||
Continue with Google
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { Spinner } from '../ReactIcons/Spinner.tsx';
|
||||
import { LinkedInIcon } from '../ReactIcons/LinkedInIcon.tsx';
|
||||
|
||||
type LinkedInButtonProps = {};
|
||||
type LinkedInButtonProps = {
|
||||
isDisabled?: boolean;
|
||||
setIsDisabled?: (isDisabled: boolean) => void;
|
||||
};
|
||||
|
||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||
|
||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
const { isDisabled, setIsDisabled } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : LinkedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -26,15 +30,17 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(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);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -55,29 +61,39 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
setIsDisabled?.(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`,
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -85,10 +101,11 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
window.location.pathname === '/respond-invite'
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname,
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
|
||||
@@ -99,21 +116,22 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
setIsDisabled?.(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
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 || isDisabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spinner className={'h-[18px] w-[18px]'} isDualRing={false} />
|
||||
) : (
|
||||
<LinkedInIcon className={'h-[18px] w-[18px]'} />
|
||||
)}
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
{error && (
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import EmailLoginForm from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
import { LinkedInButton } from './LinkedInButton';
|
||||
import { AuthenticationForm } from './AuthenticationForm';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
<div class='mb-7 text-center'>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
<LinkedInButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<EmailLoginForm client:load />
|
||||
|
||||
<AuthenticationForm client:load />
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Don't have an account?{' '}
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'> Sign up</a>
|
||||
</div>
|
||||
</Popup>
|
||||
|
||||
@@ -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 { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
import { ErrorIcon2 } from '../ReactIcons/ErrorIcon2';
|
||||
|
||||
export function TriggerVerifyAccount() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -17,7 +16,7 @@ export function TriggerVerifyAccount() {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
|
||||
{
|
||||
code,
|
||||
}
|
||||
},
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
@@ -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 = '/';
|
||||
})
|
||||
@@ -55,26 +55,14 @@ export function TriggerVerifyAccount() {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
{isLoading && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={SpinnerIcon}
|
||||
class={'mx-auto h-16 w-16 animate-spin'}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={ErrorIcon}
|
||||
className={'mx-auto h-16 w-16'}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <Spinner className="mx-auto h-16 w-16" />}
|
||||
{error && <ErrorIcon2 className="mx-auto h-16 w-16" />}
|
||||
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
|
||||
Verifying your account
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
{isLoading && <p>Please wait while we verify your account..</p>}
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
{error && <p className="text-red-700">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import VerifyLetterIcon from '../../icons/verify-letter.svg';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { VerifyLetterIcon } from '../ReactIcons/VerifyLetterIcon';
|
||||
|
||||
export function VerificationEmailMessage() {
|
||||
const [email, setEmail] = useState('..');
|
||||
@@ -37,15 +37,11 @@ export function VerificationEmailMessage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<img
|
||||
alt="Verify Email"
|
||||
src={VerifyLetterIcon}
|
||||
class="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
/>
|
||||
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
<VerifyLetterIcon className="mx-auto mb-4 h-20 w-40 sm:h-40" />
|
||||
<h2 className="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
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 +49,7 @@ export function VerificationEmailMessage() {
|
||||
soon!
|
||||
</p>
|
||||
|
||||
<hr class="my-4" />
|
||||
<hr className="my-4" />
|
||||
|
||||
{!isEmailResent && (
|
||||
<>
|
||||
@@ -72,12 +68,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>
|
||||
|
||||
@@ -36,14 +36,16 @@ function handleGuest() {
|
||||
'/account/notification',
|
||||
'/account/update-password',
|
||||
'/account/settings',
|
||||
'/account/roadmaps',
|
||||
'/account/road-card',
|
||||
'/account/friends',
|
||||
'/account',
|
||||
'/team',
|
||||
'/team/progress',
|
||||
'/team/roadmaps',
|
||||
'/team/new',
|
||||
'/team/members',
|
||||
'/team/settings'
|
||||
'/team/settings',
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
@@ -71,7 +73,10 @@ function handleAuthenticated() {
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
const authRedirect = window.localStorage.getItem('authRedirect') || '/';
|
||||
window.localStorage.removeItem('authRedirect');
|
||||
|
||||
window.location.href = authRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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,34 +1,47 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
Fragment,
|
||||
type ReactElement,
|
||||
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 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';
|
||||
import { BestPracticesIcon } from '../ReactIcons/BestPracticesIcon.tsx';
|
||||
import { UserIcon } from '../ReactIcons/UserIcon.tsx';
|
||||
import { GroupIcon } from '../ReactIcons/GroupIcon.tsx';
|
||||
import { RoadmapIcon } from '../ReactIcons/RoadmapIcon.tsx';
|
||||
import { ClipboardIcon } from '../ReactIcons/ClipboardIcon.tsx';
|
||||
import { GuideIcon } from '../ReactIcons/GuideIcon.tsx';
|
||||
import { HomeIcon } from '../ReactIcons/HomeIcon.tsx';
|
||||
import { VideoIcon } from '../ReactIcons/VideoIcon.tsx';
|
||||
|
||||
export type PageType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
icon?: ReactElement;
|
||||
isProtected?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
id: 'home',
|
||||
url: '/',
|
||||
title: 'Home',
|
||||
group: 'Pages',
|
||||
icon: <HomeIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon,
|
||||
icon: <UserIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -36,7 +49,15 @@ const defaultPages: PageType[] = [
|
||||
url: '/team',
|
||||
title: 'Teams',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon,
|
||||
icon: <GroupIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
url: '/account/friends',
|
||||
title: 'Friends',
|
||||
group: 'Pages',
|
||||
icon: <GroupIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
@@ -44,28 +65,43 @@ const defaultPages: PageType[] = [
|
||||
url: '/roadmaps',
|
||||
title: 'Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon,
|
||||
icon: <RoadmapIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'account-roadmaps',
|
||||
url: '/account/roadmaps',
|
||||
title: 'Custom Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: <RoadmapIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'best-practices',
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon,
|
||||
icon: <BestPracticesIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'questions',
|
||||
url: '/questions',
|
||||
title: 'Questions',
|
||||
group: 'Pages',
|
||||
icon: <ClipboardIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'guides',
|
||||
url: '/guides',
|
||||
title: 'Guides',
|
||||
group: 'Pages',
|
||||
icon: GuideIcon,
|
||||
icon: <GuideIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
{
|
||||
id: 'videos',
|
||||
url: '/videos',
|
||||
title: 'Videos',
|
||||
group: 'Pages',
|
||||
icon: VideoIcon,
|
||||
icon: <VideoIcon className="mr-2 h-4 w-4 stroke-2" />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -158,12 +194,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);
|
||||
@@ -175,7 +211,7 @@ export function CommandMenu() {
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1,
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
@@ -191,39 +227,37 @@ 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>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
|
||||
<span className="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && page.icon}
|
||||
{page.title}
|
||||
</a>
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Stepper } from '../Stepper';
|
||||
import { Step0, ValidTeamType } from './Step0';
|
||||
import { Step1, ValidTeamSize } from './Step1';
|
||||
import { Step0, type ValidTeamType } from './Step0';
|
||||
import { Step1, type ValidTeamSize } from './Step1';
|
||||
import { Step2 } from './Step2';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
|
||||
@@ -21,7 +21,7 @@ export function NextButton(props: NextButtonProps) {
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
type={type as any}
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
|
||||
type NotDropdownProps = {
|
||||
onClick: () => void;
|
||||
@@ -37,11 +37,7 @@ export function NotDropdown(props: NotDropdownProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<img
|
||||
alt={singularName}
|
||||
src={ChevronDownIcon}
|
||||
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
|
||||
/>
|
||||
<ChevronDownIcon className="relative top-[1px] h-[17px] w-[17px] opacity-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpGet, httpPut } from '../../lib/http';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import ChevronDownIcon from '../../icons/chevron-down.svg';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { UpdateTeamResourceModal } from './UpdateTeamResourceModal';
|
||||
import { SelectRoadmapModal } from './SelectRoadmapModal';
|
||||
import { NotDropdown } from './NotDropdown';
|
||||
import { Map, Shapes } from 'lucide-react';
|
||||
import type {
|
||||
AllowedRoadmapVisibility,
|
||||
RoadmapDocument,
|
||||
} from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export type TeamResourceConfig = {
|
||||
isCustomResource: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
removed: string[];
|
||||
topics?: number;
|
||||
sharedTeamMemberIds: string[];
|
||||
sharedFriendIds: string[];
|
||||
}[];
|
||||
|
||||
type RoadmapSelectorProps = {
|
||||
teamId: string;
|
||||
teamResourceConfig: TeamResourceConfig;
|
||||
setTeamResourceConfig: (config: TeamResourceConfig) => void;
|
||||
teamResources: TeamResourceConfig;
|
||||
setTeamResources: (config: TeamResourceConfig) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
const { teamId, teamResourceConfig = [], setTeamResourceConfig } = props;
|
||||
const { teamId, teamResources = [], setTeamResources } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [removingRoadmapId, setRemovingRoadmapId] = useState<string>('');
|
||||
const [showSelectRoadmapModal, setShowSelectRoadmapModal] = useState(false);
|
||||
const [allRoadmaps, setAllRoadmaps] = useState<PageType[]>([]);
|
||||
const [changingRoadmapId, setChangingRoadmapId] = useState<string>('');
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState<boolean>(false);
|
||||
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
async function loadAllRoadmaps() {
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message || 'Something went wrong. Please try again!');
|
||||
setError(error.message || 'Something went wrong. Please try again!');
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +88,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
async function onRemove(resourceId: string) {
|
||||
@@ -106,13 +122,25 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamResourceConfig(response);
|
||||
setTeamResources(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAllRoadmaps().finally();
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
}, []);
|
||||
|
||||
function handleCustomRoadmapCreated(roadmap: RoadmapDocument) {
|
||||
const { _id: roadmapId } = roadmap;
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadAllRoadmaps().finally(() => {});
|
||||
addTeamResource(roadmapId).finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{changingRoadmapId && (
|
||||
@@ -121,9 +149,9 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
resourceId={changingRoadmapId}
|
||||
resourceType={'roadmap'}
|
||||
teamId={teamId}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResources}
|
||||
defaultRemovedItems={
|
||||
teamResourceConfig.find((c) => c.resourceId === changingRoadmapId)
|
||||
teamResources.find((c) => c.resourceId === changingRoadmapId)
|
||||
?.removed || []
|
||||
}
|
||||
/>
|
||||
@@ -131,7 +159,7 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
{showSelectRoadmapModal && (
|
||||
<SelectRoadmapModal
|
||||
onClose={() => setShowSelectRoadmapModal(false)}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
teamResourceConfig={teamResources}
|
||||
allRoadmaps={allRoadmaps}
|
||||
teamId={teamId}
|
||||
onRoadmapAdd={(roadmapId) => {
|
||||
@@ -145,72 +173,170 @@ export function RoadmapSelector(props: RoadmapSelectorProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<NotDropdown
|
||||
<div className="my-3 flex items-center gap-4">
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
teamId={teamId}
|
||||
onClose={() => setIsCreatingRoadmap(false)}
|
||||
onCreated={(roadmap: RoadmapDocument) => {
|
||||
handleCustomRoadmapCreated(roadmap);
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
|
||||
onClick={() => {
|
||||
setShowSelectRoadmapModal(true);
|
||||
}}
|
||||
selectedCount={teamResourceConfig.length}
|
||||
singularName={'roadmap'}
|
||||
pluralName={'roadmaps'}
|
||||
/>
|
||||
>
|
||||
<Map className="h-4 w-4 stroke-[2.5]" />
|
||||
Pick from our roadmaps
|
||||
</button>
|
||||
|
||||
<span className="text-base text-gray-400">or</span>
|
||||
|
||||
<button
|
||||
className="flex h-10 grow items-center justify-center gap-2 rounded-md border border-black bg-white text-black transition-colors hover:bg-black hover:text-white"
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
<Shapes className="h-4 w-4 stroke-[2.5]" />
|
||||
Create Custom Roadmap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!teamResourceConfig.length && (
|
||||
<p className={'mb-3 mt-2 text-base text-gray-400'}>
|
||||
No roadmaps selected.
|
||||
</p>
|
||||
{!teamResources.length && (
|
||||
<div className="flex min-h-[240px] flex-col items-center justify-center rounded-lg border">
|
||||
<Map className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className={'text-lg font-semibold'}>No roadmaps selected.</p>
|
||||
<p className={'text-base text-gray-400'}>
|
||||
Pick from{' '}
|
||||
<span
|
||||
onClick={() => setShowSelectRoadmapModal(true)}
|
||||
className="cursor-pointer underline"
|
||||
>
|
||||
our roadmaps
|
||||
</span>{' '}
|
||||
or{' '}
|
||||
<span
|
||||
onClick={() => {
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
className="cursor-pointer underline"
|
||||
>
|
||||
create a new one
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamResourceConfig.length > 0 && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 flex-wrap gap-2.5">
|
||||
{teamResourceConfig.map(({ resourceId, removed: removedTopics }) => {
|
||||
const roadmapTitle =
|
||||
allRoadmaps.find((roadmap) => roadmap.id === resourceId)?.title ||
|
||||
'...';
|
||||
{teamResources.length > 0 && (
|
||||
<div className="mb-3 grid grid-cols-1 flex-wrap gap-2.5 sm:grid-cols-3">
|
||||
{teamResources.map(
|
||||
({
|
||||
isCustomResource,
|
||||
title: roadmapTitle,
|
||||
resourceId,
|
||||
removed: removedTopics,
|
||||
topics,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col items-start overflow-hidden rounded-md border border-gray-300"
|
||||
key={resourceId}
|
||||
>
|
||||
<div className={'w-full flex-grow px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-snug text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 || (topics && topics > 0) ? (
|
||||
<span className={'text-xs leading-none text-gray-400'}>
|
||||
{isCustomResource ? (
|
||||
<>
|
||||
Custom · {topics} topic
|
||||
{topics && topics > 1 ? 's' : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
{isCustomResource
|
||||
? 'Placeholder roadmap.'
|
||||
: 'No changes made ..'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start rounded-md border border-gray-300">
|
||||
<div className={'w-full px-3 pb-2 pt-4'}>
|
||||
<span className="mb-0.5 block text-base font-medium leading-none text-black">
|
||||
{roadmapTitle}
|
||||
</span>
|
||||
{removedTopics.length > 0 ? (
|
||||
<span className={'text-xs leading-none text-gray-900'}>
|
||||
{removedTopics.length} topic
|
||||
{removedTopics.length > 1 ? 's' : ''} removed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs italic leading-none text-gray-400/60">
|
||||
No changes made ..
|
||||
</span>
|
||||
{removingRoadmapId === resourceId && (
|
||||
<div
|
||||
className={
|
||||
'flex w-full items-center justify-end p-3 text-sm'
|
||||
}
|
||||
>
|
||||
<span className="text-xs text-gray-500">
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={() => onRemove(resourceId)}
|
||||
className="mx-0.5 text-red-500 underline underline-offset-1"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setRemovingRoadmapId('')}
|
||||
className="text-red-500 underline underline-offset-1"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(!removingRoadmapId || removingRoadmapId !== resourceId) && (
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => {
|
||||
if (isCustomResource) {
|
||||
window.open(
|
||||
`${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${resourceId}`,
|
||||
'_blank'
|
||||
);
|
||||
return;
|
||||
}
|
||||
setChangingRoadmapId(resourceId);
|
||||
}}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => setRemovingRoadmapId(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'flex w-full justify-between p-3'}>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-gray-500 underline hover:text-black focus:outline-none'
|
||||
}
|
||||
onClick={() => setChangingRoadmapId(resourceId)}
|
||||
>
|
||||
Customize
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'text-xs text-red-500 underline hover:text-black'
|
||||
}
|
||||
onClick={() => onRemove(resourceId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
const allowedRoles = [
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import type { PageType } from '../CommandMenu/CommandMenu';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import { SelectRoadmapModalItem } from './SelectRoadmapModalItem';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
export type SelectRoadmapModalProps = {
|
||||
teamId: string;
|
||||
@@ -60,27 +60,27 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
setSearchResults(searchResults);
|
||||
}, [searchText, allRoadmaps]);
|
||||
|
||||
const roleBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('role-roadmap')
|
||||
const roleBasedRoadmaps = searchResults.filter(
|
||||
(roadmap) => roadmap?.metadata?.tags?.includes('role-roadmap'),
|
||||
);
|
||||
const skillBasedRoadmaps = searchResults.filter((roadmap) =>
|
||||
roadmap?.metadata?.tags?.includes('skill-roadmap')
|
||||
const skillBasedRoadmaps = searchResults.filter(
|
||||
(roadmap) => roadmap?.metadata?.tags?.includes('skill-roadmap'),
|
||||
);
|
||||
|
||||
return (
|
||||
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div class="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
|
||||
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
|
||||
<div
|
||||
ref={popupBodyEl}
|
||||
class="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
|
||||
className="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="popup-close absolute right-2.5 top-3 ml-auto inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-100 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img alt={'close'} src={CloseIcon} className="h-4 w-4" />
|
||||
<span class="sr-only">Close modal</span>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close modal</span>
|
||||
</button>
|
||||
<input
|
||||
ref={searchInputEl}
|
||||
@@ -100,12 +100,13 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
{roleBasedRoadmaps.length > 0 && (
|
||||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||||
{roleBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
const isSelected = !!teamResourceConfig?.find(
|
||||
(r) => r.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
key={roadmap.id}
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
@@ -126,11 +127,12 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{skillBasedRoadmaps.map((roadmap) => {
|
||||
const isSelected = !!teamResourceConfig.find(
|
||||
(r) => r.resourceId === roadmap.id
|
||||
(r) => r.resourceId === roadmap.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectRoadmapModalItem
|
||||
key={roadmap.id}
|
||||
title={roadmap.title}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import BuildingIcon from '../../icons/building.svg';
|
||||
import UsersIcon from '../../icons/users.svg';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { httpPut } from '../../lib/http';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState } from 'react';
|
||||
import { NextButton } from './NextButton';
|
||||
import { BuildingIcon } from '../ReactIcons/BuildingIcon.tsx';
|
||||
import { UsersIcon } from '../ReactIcons/UsersIcon.tsx';
|
||||
|
||||
export const validTeamTypes = [
|
||||
{
|
||||
value: 'company',
|
||||
label: 'Company',
|
||||
icon: BuildingIcon,
|
||||
description: 'Track the skills and learning progress of the tech team at your company',
|
||||
description:
|
||||
'Track the skills and learning progress of the tech team at your company',
|
||||
},
|
||||
{
|
||||
value: 'study_group',
|
||||
label: 'Study Group',
|
||||
icon: UsersIcon,
|
||||
description: 'Invite your friends or course-mates and track your learning progress together',
|
||||
description:
|
||||
'Invite your friends or course-mates and track your learning progress together',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -54,7 +56,7 @@ export function Step0(props: Step0Props) {
|
||||
teamSize: team.teamSize,
|
||||
linkedInUrl: team?.links?.linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -70,27 +72,28 @@ export function Step0(props: Step0Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col sm:flex-row gap-3'}>
|
||||
<div className={'flex flex-col gap-3 sm:flex-row'}>
|
||||
{validTeamTypes.map((validTeamType) => (
|
||||
<button
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 py-12 ${
|
||||
key={validTeamType.value}
|
||||
className={`flex flex-grow flex-col items-center rounded-lg border px-5 pb-10 pt-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' : ''
|
||||
}`}
|
||||
/>
|
||||
{
|
||||
<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]">
|
||||
<span className="text-sm leading-[21px] text-gray-500">
|
||||
{validTeamType.description}
|
||||
</span>
|
||||
</button>
|
||||
@@ -100,11 +103,11 @@ export function Step0(props: Step0Props) {
|
||||
{/*Error message*/}
|
||||
{error && <div className="mt-4 text-sm text-red-500">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||
<a
|
||||
href="/account"
|
||||
className={
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-red-500 text-center'
|
||||
'rounded-md border border-red-400 bg-white px-8 py-2 text-center text-red-500'
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { AppError, httpPost, httpPut } from '../../lib/http';
|
||||
import { type FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { type AppError, httpPost, httpPut } from '../../lib/http';
|
||||
import type { ValidTeamType } from './Step0';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
@@ -49,7 +49,7 @@ export function Step1(props: Step1Props) {
|
||||
team?.teamSize || ('' as any)
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
if (!name || !selectedTeamType) {
|
||||
@@ -124,7 +124,7 @@ export function Step1(props: Step1Props) {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
htmlFor="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
|
||||
@@ -133,7 +133,7 @@ export function Step1(props: Step1Props) {
|
||||
type="text"
|
||||
name="name"
|
||||
ref={nameRef as any}
|
||||
autofocus={true}
|
||||
autoFocus={true}
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Roadmap Inc."
|
||||
@@ -147,7 +147,7 @@ export function Step1(props: Step1Props) {
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="website"
|
||||
htmlFor="website"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Website
|
||||
@@ -168,7 +168,7 @@ export function Step1(props: Step1Props) {
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
Company LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
@@ -187,7 +187,7 @@ export function Step1(props: Step1Props) {
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
GitHub Organization URL
|
||||
</label>
|
||||
<input
|
||||
@@ -205,7 +205,7 @@ export function Step1(props: Step1Props) {
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label
|
||||
for="team-size"
|
||||
htmlFor="team-size"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Tech Team Size
|
||||
@@ -221,11 +221,11 @@ export function Step1(props: Step1Props) {
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="" selected>
|
||||
<option value="">
|
||||
Select team size
|
||||
</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option value={size}>{size} people</option>
|
||||
<option key={size} value={size}>{size} people</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -237,7 +237,7 @@ export function Step1(props: Step1Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-col items-center justify-between gap-2 md:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
|
||||
import { RoadmapSelector, type TeamResourceConfig } from './RoadmapSelector';
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
|
||||
type Step2Props = {
|
||||
@@ -17,7 +17,9 @@ export function Step2(props: Step2Props) {
|
||||
<>
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-1 md:mb-1.5 text-lg md:text-2xl font-bold">Select Roadmaps</h2>
|
||||
<h2 className="mb-1 text-lg font-bold md:mb-1.5 md:text-2xl">
|
||||
Select Roadmaps
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
You can always add and customize your roadmaps later.
|
||||
</p>
|
||||
@@ -25,12 +27,12 @@ export function Step2(props: Step2Props) {
|
||||
|
||||
<RoadmapSelector
|
||||
teamId={team._id!}
|
||||
teamResourceConfig={teamResourceConfig}
|
||||
setTeamResourceConfig={setTeamResourceConfig}
|
||||
teamResources={teamResourceConfig}
|
||||
setTeamResources={setTeamResourceConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-2">
|
||||
<div className="mt-4 flex flex-col items-stretch justify-between gap-2 md:flex-row md:items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -46,8 +48,9 @@ export function Step2(props: Step2Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={teamResourceConfig.length !== 0}
|
||||
className={
|
||||
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto'
|
||||
'flex-grow rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black md:flex-auto disabled:opacity-50 disabled:pointer-events-none'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TeamDocument } from './CreateTeamForm';
|
||||
import { NextButton } from './NextButton';
|
||||
import { TrashIcon } from '../ReactIcons/TrashIcon';
|
||||
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { type AllowedRoles, RoleDropdown } from './RoleDropdown';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type Step3Props = {
|
||||
@@ -75,9 +75,9 @@ export function Step3(props: Step3Props) {
|
||||
|
||||
return (
|
||||
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
|
||||
<div class="mb-1 mt-2">
|
||||
<h2 class="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
|
||||
<p class="text-sm text-gray-700">
|
||||
<div className="mb-1 mt-2">
|
||||
<h2 className="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
|
||||
<p className="text-sm text-gray-700">
|
||||
Use the form below to invite your team members to your team. You can
|
||||
also invite them later.
|
||||
</p>
|
||||
@@ -88,7 +88,7 @@ export function Step3(props: Step3Props) {
|
||||
<div className="flex flex-col sm:flex-row gap-2" key={user.id}>
|
||||
<input
|
||||
ref={userCounter === users.length - 1 ? emailInputRef : null}
|
||||
autofocus={true}
|
||||
autoFocus={true}
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
@@ -178,8 +178,9 @@ export function Step3(props: Step3Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={users.filter((u) => u.email).length !== 0}
|
||||
className={
|
||||
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black'
|
||||
'rounded-md flex-grow md:flex-auto border border-gray-300 bg-white px-4 py-2 text-gray-500 hover:border-gray-400 hover:text-black disabled:opacity-50 disabled:pointer-events-none'
|
||||
}
|
||||
>
|
||||
Skip for Now
|
||||
|
||||