Compare commits
276 Commits
fix/guide-
...
feat/linke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e0f7926e | ||
|
|
510e6fd273 | ||
|
|
2ca98bbb10 | ||
|
|
49cff0c22c | ||
|
|
943bf41dc5 | ||
|
|
6c9ba75906 | ||
|
|
70976ee42a | ||
|
|
5848698abf | ||
|
|
29dd1eb21f | ||
|
|
ebe6d3c6e4 | ||
|
|
425bfea265 | ||
|
|
c58efe8d00 | ||
|
|
955d04e532 | ||
|
|
0031a9c6ba | ||
|
|
8fb778337d | ||
|
|
a48d39a863 | ||
|
|
36b2a8f2d7 | ||
|
|
00e9d44ba9 | ||
|
|
62b068a94a | ||
|
|
af926002e9 | ||
|
|
0612f9c44f | ||
|
|
fbf545c2ed | ||
|
|
c7ef97cb4f | ||
|
|
564f48540e | ||
|
|
52e729d212 | ||
|
|
bdfa7606dd | ||
|
|
056e0e8e3a | ||
|
|
879ba258b2 | ||
|
|
3d62d2689f | ||
|
|
ac892d2868 | ||
|
|
19bde7bb2f | ||
|
|
419b1872b8 | ||
|
|
bbeb4ee279 | ||
|
|
f2ca7d9140 | ||
|
|
70b95c6ad1 | ||
|
|
5a3f621093 | ||
|
|
631eb380fc | ||
|
|
cb9778ba15 | ||
|
|
38106a8199 | ||
|
|
226e94857b | ||
|
|
f94c701657 | ||
|
|
259109cc38 | ||
|
|
e120df30e3 | ||
|
|
43f351a943 | ||
|
|
502b8e20d5 | ||
|
|
ff5858f965 | ||
|
|
8b8ef52d98 | ||
|
|
7032bc0726 | ||
|
|
ba65dec596 | ||
|
|
78cf88fbd9 | ||
|
|
93e16d899a | ||
|
|
14060bda94 | ||
|
|
45b729d708 | ||
|
|
9023ea6298 | ||
|
|
d29176cf98 | ||
|
|
55989d8480 | ||
|
|
9c936974c7 | ||
|
|
311b4683d0 | ||
|
|
bf61697154 | ||
|
|
52818f1e34 | ||
|
|
174ea05a92 | ||
|
|
dcb4e06fea | ||
|
|
62eb6a4a01 | ||
|
|
f643f3bd9a | ||
|
|
972370e0e6 | ||
|
|
a6feb72339 | ||
|
|
c751706631 | ||
|
|
8900324234 | ||
|
|
f1b880d898 | ||
|
|
9a285d7470 | ||
|
|
15259560e0 | ||
|
|
d8afa166aa | ||
|
|
d39791257e | ||
|
|
06b7005782 | ||
|
|
bc6c933440 | ||
|
|
b965a89db3 | ||
|
|
9b82e327e2 | ||
|
|
5808125d92 | ||
|
|
f49fe258aa | ||
|
|
08df9e8c33 | ||
|
|
56e388edd8 | ||
|
|
ded75c7af1 | ||
|
|
557c426078 | ||
|
|
d61a83a0a3 | ||
|
|
7500c6c1cb | ||
|
|
b51076dd0a | ||
|
|
8010bfc832 | ||
|
|
0f80f26d17 | ||
|
|
40d25c43f4 | ||
|
|
686a7382ab | ||
|
|
88401bd7b1 | ||
|
|
1d97467c05 | ||
|
|
2388fa148b | ||
|
|
d574fccbc8 | ||
|
|
89cc55a1eb | ||
|
|
8c75354235 | ||
|
|
9eb9dc8cd8 | ||
|
|
afa28bddd3 | ||
|
|
5cf0e76765 | ||
|
|
16b3f8ff49 | ||
|
|
d2055e0f6d | ||
|
|
4010157baf | ||
|
|
75c7e83264 | ||
|
|
8ca22e0dcc | ||
|
|
2b8d1f470c | ||
|
|
c4d9651e95 | ||
|
|
813c0ebd93 | ||
|
|
e376942c8d | ||
|
|
6d91c11856 | ||
|
|
1d47b1fb7b | ||
|
|
54a9e586bf | ||
|
|
b58c2a1356 | ||
|
|
dec5e58063 | ||
|
|
b0a4130229 | ||
|
|
a06e992b8a | ||
|
|
6e1072bea9 | ||
|
|
1f9eb18bfb | ||
|
|
603bd2b107 | ||
|
|
0163d9e4f9 | ||
|
|
910579f463 | ||
|
|
d6a28a312a | ||
|
|
267a4a7be5 | ||
|
|
59111a1a90 | ||
|
|
9f5d1aef74 | ||
|
|
36eed57ec2 | ||
|
|
cc054bb24b | ||
|
|
056256015d | ||
|
|
dd5f3795ec | ||
|
|
8c29d43bef | ||
|
|
aa32258aa1 | ||
|
|
d2394aca77 | ||
|
|
6804535fe4 | ||
|
|
3852e7d96f | ||
|
|
eb852caee8 | ||
|
|
1414693e33 | ||
|
|
fbdb7e77c3 | ||
|
|
c72658938f | ||
|
|
718c582a8c | ||
|
|
12f385dffd | ||
|
|
35f500d218 | ||
|
|
44949709d1 | ||
|
|
476557db80 | ||
|
|
f7625a8250 | ||
|
|
c06c236da5 | ||
|
|
24c262282e | ||
|
|
876330522d | ||
|
|
f1c771e95c | ||
|
|
d3668b25e9 | ||
|
|
b0493c370c | ||
|
|
e67caa0ffe | ||
|
|
82a44ddfef | ||
|
|
205fe6cc23 | ||
|
|
591cac8bfa | ||
|
|
42debdeab0 | ||
|
|
0555452bf2 | ||
|
|
cc7f9d94bb | ||
|
|
51d986b86f | ||
|
|
83057d65cd | ||
|
|
b886f20570 | ||
|
|
dacd2d898b | ||
|
|
a2490efa80 | ||
|
|
e087b79ade | ||
|
|
10b1a8cb07 | ||
|
|
f2c06462fa | ||
|
|
ad7ba44a2e | ||
|
|
7a72c96e79 | ||
|
|
d955044a3b | ||
|
|
b86fafd538 | ||
|
|
c52a4e6638 | ||
|
|
9d66da6bf9 | ||
|
|
4b76d0b7aa | ||
|
|
626026eebc | ||
|
|
fdd12acb8e | ||
|
|
02015826ff | ||
|
|
5d07ce32d8 | ||
|
|
3967b16d25 | ||
|
|
f325183691 | ||
|
|
a029850531 | ||
|
|
d3d2ae5889 | ||
|
|
4a049b2a7a | ||
|
|
fd349f2da8 | ||
|
|
f338bd5ecb | ||
|
|
a3470cd844 | ||
|
|
f4635d794f | ||
|
|
426fe44dc8 | ||
|
|
4ed39cec1a | ||
|
|
b1f0844546 | ||
|
|
88aa7e4024 | ||
|
|
471f6348f1 | ||
|
|
9dfb70c941 | ||
|
|
6fa7e0d1c0 | ||
|
|
5ccfa654ec | ||
|
|
1c67068eab | ||
|
|
f5ff2a0823 | ||
|
|
58503f67f3 | ||
|
|
5dd0479caf | ||
|
|
7441f1a203 | ||
|
|
4d3ebb0ac6 | ||
|
|
47d5716238 | ||
|
|
94d888a61e | ||
|
|
ddd43a1514 | ||
|
|
2cf94f981b | ||
|
|
f1973f63c2 | ||
|
|
dfb67e17d5 | ||
|
|
48239772f6 | ||
|
|
1cea9d0e13 | ||
|
|
6591c36ef4 | ||
|
|
41de9c47b0 | ||
|
|
0ba1a8a1d1 | ||
|
|
6fcb153244 | ||
|
|
7a8d97b1cd | ||
|
|
9e37076d0d | ||
|
|
f8e5661353 | ||
|
|
4d4cda6cac | ||
|
|
8b528f39f2 | ||
|
|
e1a04b4a20 | ||
|
|
f0e8ffe565 | ||
|
|
f9c1e64235 | ||
|
|
0174c9156b | ||
|
|
2ee81e6ff3 | ||
|
|
42ab5a3e9e | ||
|
|
e9fa663410 | ||
|
|
2d17a267be | ||
|
|
40371cdded | ||
|
|
6bb315a2fc | ||
|
|
fc2c9a1439 | ||
|
|
b50935ecd6 | ||
|
|
9b73d60c5d | ||
|
|
504ee8cf5e | ||
|
|
057bbddd9f | ||
|
|
4063979c2a | ||
|
|
da391fe9ed | ||
|
|
953ca9257c | ||
|
|
396bedd319 | ||
|
|
e05269f117 | ||
|
|
77c7ca8835 | ||
|
|
e877f5c610 | ||
|
|
42e1a79697 | ||
|
|
ce32cdc8a4 | ||
|
|
e2a0bd23c0 | ||
|
|
98f0ebde8b | ||
|
|
bc018f8b39 | ||
|
|
03bd478aaa | ||
|
|
67a8582c22 | ||
|
|
7533575df9 | ||
|
|
34fcd74d93 | ||
|
|
1558feb734 | ||
|
|
bc4d9f9e2f | ||
|
|
4142c7b51e | ||
|
|
e36a749223 | ||
|
|
e69d9b4238 | ||
|
|
3132a39816 | ||
|
|
03f9fa51ff | ||
|
|
e2062aefe9 | ||
|
|
855ba7bbfb | ||
|
|
ad71b6398d | ||
|
|
0ea0629104 | ||
|
|
8b2f12fcdd | ||
|
|
e66bff74bf | ||
|
|
58ea34bb49 | ||
|
|
275c2c3c88 | ||
|
|
f13c29adad | ||
|
|
ec9f836a1f | ||
|
|
589d157be5 | ||
|
|
a2719bc771 | ||
|
|
c5645299aa | ||
|
|
6aac3f296c | ||
|
|
137635f11a | ||
|
|
8487d2f443 | ||
|
|
a7bee1fea7 | ||
|
|
43292de507 | ||
|
|
bee30defb5 | ||
|
|
52649a2d3c | ||
|
|
be47ac6573 | ||
|
|
24ce27090e | ||
|
|
8dd0225720 |
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
||||
PUBLIC_AVATAR_BASE_URL=https://dodrc8eu8m09s.cloudfront.net/avatars
|
||||
2
.github/workflows/deploy.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
PUBLIC_AVATAR_BASE_URL: "https://dodrc8eu8m09s.cloudfront.net/avatars"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PAT: ${{ secrets.PAT }}
|
||||
CI: true
|
||||
|
||||
43
.github/workflows/update-sponsors.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Update Sponsors
|
||||
|
||||
on:
|
||||
workflow_dispatch: # allow manual run
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # run daily at 00:00 UTC
|
||||
|
||||
env:
|
||||
SPONSOR_SHEET_API_KEY: ${{ secrets.SPONSOR_SHEET_API_KEY }}
|
||||
SPONSOR_SHEET_ID: ${{ secrets.SPONSOR_SHEET_ID }}
|
||||
|
||||
jobs:
|
||||
update-sponsors:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- uses: pnpm/action-setup@v2.2.2
|
||||
with:
|
||||
version: 7.13.4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install
|
||||
- name: Update sponsors
|
||||
run: |
|
||||
node bin/update-sponsors.cjs
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
delete-branch: false
|
||||
branch: 'update-sponsors'
|
||||
base: 'master'
|
||||
labels: |
|
||||
sponsors
|
||||
automated pr
|
||||
reviewers: kamranahmedse
|
||||
commit-message: 'chore: update sponsors'
|
||||
title: 'Update Sponsor Banners'
|
||||
body: |
|
||||
Updates sponsor banners.
|
||||
Please review the changes and merge if everything looks good.
|
||||
7
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.idea
|
||||
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
@@ -5,7 +7,7 @@ dist/
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
bin/developer-roadmap
|
||||
scripts/developer-roadmap
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
@@ -23,4 +25,5 @@ pnpm-debug.log*
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
tests-examples
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// https://astro.build/config
|
||||
import preact from '@astrojs/preact';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://roadmap.sh/',
|
||||
markdown: {
|
||||
@@ -43,6 +46,22 @@ export default defineConfig({
|
||||
format: 'file',
|
||||
},
|
||||
integrations: [
|
||||
{
|
||||
name: 'client-authenticated',
|
||||
hooks: {
|
||||
'astro:config:setup'(options) {
|
||||
options.addClientDirective({
|
||||
name: 'authenticated',
|
||||
entrypoint: fileURLToPath(
|
||||
new URL(
|
||||
'./src/directives/client-authenticated.mjs',
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tailwind({
|
||||
config: {
|
||||
applyBaseStyles: false,
|
||||
@@ -56,5 +75,6 @@ export default defineConfig({
|
||||
css: false,
|
||||
js: false,
|
||||
}),
|
||||
preact(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const jsonsDir = path.join(process.cwd(), 'public/jsons');
|
||||
const childJsonDirs = fs.readdirSync(jsonsDir);
|
||||
|
||||
childJsonDirs.forEach((childJsonDir) => {
|
||||
const fullChildJsonDirPath = path.join(jsonsDir, childJsonDir);
|
||||
const jsonFiles = fs.readdirSync(fullChildJsonDirPath);
|
||||
|
||||
jsonFiles.forEach((jsonFileName) => {
|
||||
console.log(`Compressing ${jsonFileName}...`);
|
||||
|
||||
const jsonFilePath = path.join(fullChildJsonDirPath, jsonFileName);
|
||||
const json = require(jsonFilePath);
|
||||
|
||||
fs.writeFileSync(jsonFilePath, JSON.stringify(json));
|
||||
});
|
||||
});
|
||||
44
package.json
@@ -11,34 +11,44 @@
|
||||
"format": "prettier --write .",
|
||||
"astro": "astro",
|
||||
"deploy": "NODE_DEBUG=gh-pages gh-pages -d dist -t",
|
||||
"compress:jsons": "node bin/compress-jsons.cjs",
|
||||
"compress:jsons": "node scripts/compress-jsons.cjs",
|
||||
"upgrade": "ncu -u",
|
||||
"roadmap-links": "node bin/roadmap-links.cjs",
|
||||
"roadmap-dirs": "node bin/roadmap-dirs.cjs",
|
||||
"roadmap-content": "node bin/roadmap-content.cjs",
|
||||
"best-practice-dirs": "node bin/best-practice-dirs.cjs",
|
||||
"roadmap-links": "node scripts/roadmap-links.cjs",
|
||||
"roadmap-dirs": "node scripts/roadmap-dirs.cjs",
|
||||
"roadmap-content": "node scripts/roadmap-content.cjs",
|
||||
"best-practice-dirs": "node scripts/best-practice-dirs.cjs",
|
||||
"best-practice-content": "node scripts/best-practice-content.cjs",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^1.2.1",
|
||||
"@astrojs/tailwind": "^3.1.1",
|
||||
"astro": "^2.1.7",
|
||||
"astro-compress": "^1.1.35",
|
||||
"@astrojs/preact": "^2.2.1",
|
||||
"@astrojs/sitemap": "^1.3.3",
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.5.0",
|
||||
"astro": "^2.6.3",
|
||||
"astro-compress": "^1.1.47",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.9.1",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.8.0",
|
||||
"rehype-external-links": "^2.0.1",
|
||||
"roadmap-renderer": "^1.0.4",
|
||||
"tailwindcss": "^3.2.7"
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"preact": "^10.15.1",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"tailwindcss": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.32.1",
|
||||
"@playwright/test": "^1.35.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"csv-parser": "^3.0.0",
|
||||
"gh-pages": "^5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.2.1",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-astro": "^0.8.0",
|
||||
"prettier-plugin-tailwindcss": "^0.2.6"
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-astro": "^0.10.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
7091
pnpm-lock.yaml
generated
@@ -1,13 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 36 36">
|
||||
<path fill="#000" d="M22.25 4h-8.5a1 1 0 0 0-.96.73l-5.54 19.4a.5.5 0 0 0 .62.62l5.05-1.44a2 2 0 0 0 1.38-1.4l3.22-11.66a.5.5 0 0 1 .96 0l3.22 11.67a2 2 0 0 0 1.38 1.39l5.05 1.44a.5.5 0 0 0 .62-.62l-5.54-19.4a1 1 0 0 0-.96-.73Z"/>
|
||||
<path fill="url(#gradient)" d="M18 28a7.63 7.63 0 0 1-5-2c-1.4 2.1-.35 4.35.6 5.55.14.17.41.07.47-.15.44-1.8 2.93-1.22 2.93.6 0 2.28.87 3.4 1.72 3.81.34.16.59-.2.49-.56-.31-1.05-.29-2.46 1.29-3.25 3-1.5 3.17-4.83 2.5-6-.67.67-2.6 2-5 2Z"/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="16" x2="16" y1="32" y2="24" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#000"/>
|
||||
<stop offset="1" stop-color="#000" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<style>
|
||||
@media (prefers-color-scheme:dark){:root{filter:invert(100%)}}
|
||||
</style>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 873 B |
BIN
public/guides/llms.png
Normal file
|
After Width: | Height: | Size: 691 KiB |
BIN
public/images/default-avatar.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/images/features/in-progress.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
public/images/partners/ambassador-graphic-1.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/images/partners/ambassador-graphic-2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 101 KiB |
BIN
public/images/partners/apollo-event.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/images/partners/apollo-learning.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/images/partners/apollo-workshop.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
public/images/partners/graphql-summit.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
public/images/partners/honeycomb-ebook.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 62 KiB |
BIN
public/pdfs/best-practices/code-review.pdf
Normal file
BIN
public/pdfs/roadmaps/android.pdf
Normal file
BIN
public/pdfs/roadmaps/code-review.pdf
Normal file
BIN
public/pdfs/roadmaps/cpp.pdf
Normal file
BIN
public/pdfs/roadmaps/docker.pdf
Normal file
BIN
public/pdfs/roadmaps/full-stack.pdf
Normal file
BIN
public/pdfs/roadmaps/postgresql-dba.pdf
Normal file
BIN
public/pdfs/roadmaps/prompt-engineering.pdf
Normal file
BIN
public/roadmaps/cpp.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/roadmaps/docker.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/roadmaps/full-stack.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
21
readme.md
@@ -4,16 +4,16 @@
|
||||
<p align="center">Community driven roadmaps, articles and resources for developers<p>
|
||||
<p align="center">
|
||||
<a href="https://roadmap.sh/roadmaps">
|
||||
<img src="https://img.shields.io/badge/-Roadmaps%20-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Roadmaps%20-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
</a>
|
||||
<a href="https://roadmap.sh/best-practices">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
|
||||
</a>
|
||||
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://github.com/kamranahmedse/developer-roadmap/tree/0471d44c8fae58b6a36a7c57bba12253916d0249/translations">
|
||||
<img src="https://img.shields.io/badge/-Translations-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9D%A4-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
@@ -30,15 +30,17 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
|
||||
|
||||
Here is the list of available roadmaps with more being actively worked upon.
|
||||
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend)
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
|
||||
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
|
||||
- [JavaScript Roadmap](https://roadmap.sh/javascript)
|
||||
- [TypeScript Roadmap](https://roadmap.sh/typescript)
|
||||
- [C++ Roadmap](https://roadmap.sh/cpp)
|
||||
- [React Roadmap](https://roadmap.sh/react)
|
||||
- [Vue Roadmap](https://roadmap.sh/vue)
|
||||
- [Angular Roadmap](https://roadmap.sh/angular)
|
||||
@@ -59,9 +61,12 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Cyber Security Roadmap](https://roadmap.sh/cyber-security)
|
||||
- [MongoDB Roadmap](https://roadmap.sh/mongodb)
|
||||
- [UX Design Roadmap](https://roadmap.sh/ux-design)
|
||||
- [Docker Roadmap](https://roadmap.sh/docker)
|
||||
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
|
||||
|
||||
We have also added a new form of visual content covering best practices:
|
||||
|
||||
- [Code Review Best Practices](https://roadmap.sh/best-practices/code-review)
|
||||
- [Frontend Performance Best Practices](https://roadmap.sh/best-practices/frontend-performance)
|
||||
- [API Security Best Practices](https://roadmap.sh/best-practices/api-security)
|
||||
- [AWS Best Practices](https://roadmap.sh/best-practices/aws)
|
||||
|
||||
173
scripts/best-practice-content.cjs
Normal file
@@ -0,0 +1,173 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
|
||||
const ALL_BEST_PRACTICES_DIR = path.join(
|
||||
__dirname,
|
||||
'../src/data/best-practices'
|
||||
);
|
||||
const BEST_PRACTICE_JSON_DIR = path.join(
|
||||
__dirname,
|
||||
'../public/jsons/best-practices'
|
||||
);
|
||||
|
||||
const bestPracticeId = process.argv[2];
|
||||
const bestPracticeTitle = bestPracticeId.replace(/-/g, ' ');
|
||||
|
||||
const allowedBestPracticeIds = fs.readdirSync(ALL_BEST_PRACTICES_DIR);
|
||||
if (!bestPracticeId) {
|
||||
console.error('bestPracticeId is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!allowedBestPracticeIds.includes(bestPracticeId)) {
|
||||
console.error(`Invalid bestPractice key ${bestPracticeId}`);
|
||||
console.error(`Allowed keys are ${allowedBestPracticeIds.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const BEST_PRACTICE_CONTENT_DIR = path.join(
|
||||
ALL_BEST_PRACTICES_DIR,
|
||||
bestPracticeId,
|
||||
'content'
|
||||
);
|
||||
const { Configuration, OpenAIApi } = require('openai');
|
||||
const configuration = new Configuration({
|
||||
apiKey: OPEN_AI_API_KEY,
|
||||
});
|
||||
|
||||
const openai = new OpenAIApi(configuration);
|
||||
|
||||
function getFilesInFolder(folderPath, fileList = {}) {
|
||||
const files = fs.readdirSync(folderPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(folderPath, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
getFilesInFolder(filePath, fileList);
|
||||
} else if (stats.isFile()) {
|
||||
const fileUrl = filePath
|
||||
.replace(BEST_PRACTICE_CONTENT_DIR, '') // Remove the content folder
|
||||
.replace(/\/\d+-/g, '/') // Remove ordering info `/101-ecosystem`
|
||||
.replace(/\/index\.md$/, '') // Make the `/index.md` to become the parent folder only
|
||||
.replace(/\.md$/, ''); // Remove `.md` from the end of file
|
||||
|
||||
fileList[fileUrl] = filePath;
|
||||
}
|
||||
});
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
function writeTopicContent(topicTitle) {
|
||||
let prompt = `I am reading a guide that has best practices about "${bestPracticeTitle}". I want to know more about "${topicTitle}". Write me a brief introductory paragraph about this and some tips on how I make sure of this? Behave as if you are the author of the guide.`;
|
||||
|
||||
console.log(`Generating '${topicTitle}'...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai
|
||||
.createChatCompletion({
|
||||
model: 'gpt-4',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
const article = response.data.choices[0].message.content;
|
||||
|
||||
resolve(article);
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = `/${topicId}`;
|
||||
if (currTopicUrl.startsWith('/check:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
|
||||
|
||||
if (!contentFilePath) {
|
||||
console.log(`Missing file for: ${currTopicUrl}`);
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
|
||||
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
|
||||
|
||||
if (!isFileEmpty) {
|
||||
console.log(`Ignoring ${topicId}. Not empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let newFileContent = `# ${topicTitle}`;
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log(`Writing ${topicId}..`);
|
||||
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(topicTitle);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
// console.log(topicTitle);
|
||||
// console.log(topicUrlToPathMapping[currTopicUrl]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const topicUrlToPathMapping = getFilesInFolder(BEST_PRACTICE_CONTENT_DIR);
|
||||
|
||||
const bestPracticeJson = require(path.join(
|
||||
BEST_PRACTICE_JSON_DIR,
|
||||
`${bestPracticeId}.json`
|
||||
));
|
||||
const groups = bestPracticeJson?.mockup?.controls?.control?.filter(
|
||||
(control) =>
|
||||
control.typeID === '__group__' &&
|
||||
!control.properties?.controlName?.startsWith('ext_link')
|
||||
);
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log('----------------------------------------');
|
||||
console.log('OPEN_AI_API_KEY not found. Skipping openai api calls...');
|
||||
console.log('----------------------------------------');
|
||||
}
|
||||
|
||||
const writePromises = [];
|
||||
for (let group of groups) {
|
||||
writePromises.push(writeFileForGroup(group, topicUrlToPathMapping));
|
||||
}
|
||||
|
||||
console.log('Waiting for all files to be written...');
|
||||
await Promise.all(writePromises);
|
||||
}
|
||||
|
||||
run()
|
||||
.then(() => {
|
||||
console.log('Done');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
129
scripts/page-data-agg.cjs
Normal file
@@ -0,0 +1,129 @@
|
||||
const csv = require('csv-parser');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const csvFilePath = path.join(__dirname, '../data.csv');
|
||||
|
||||
const results = {};
|
||||
const pageSummary = {};
|
||||
|
||||
fs.createReadStream(csvFilePath)
|
||||
.pipe(
|
||||
csv({
|
||||
separator: ',',
|
||||
mapHeaders: ({ header, index }) =>
|
||||
header.toLowerCase().replace(/ /g, '_'),
|
||||
mapValues: ({ header, index, value }) => {
|
||||
if (header === 'page') {
|
||||
return (
|
||||
value
|
||||
.replace(/"/g, '')
|
||||
.replace(/'/g, '')
|
||||
.replace(/`/g, '')
|
||||
.replace(/\?r=/g, '#r#')
|
||||
.replace(/\?.+?$/g, '')
|
||||
.replace(/#r#/g, '?r=')
|
||||
.replace(/\/$/g, '') || '/'
|
||||
);
|
||||
}
|
||||
|
||||
if (header !== 'month_of_year') {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
})
|
||||
)
|
||||
.on('data', (data) => {
|
||||
const { page, month_of_year, unique_pageviews, users } = data;
|
||||
const pageData = results[page] || {};
|
||||
const existingPageMonthData = pageData[month_of_year] || {};
|
||||
|
||||
const existingViews = existingPageMonthData.views || 0;
|
||||
const existingUsers = existingPageMonthData.users || 0;
|
||||
|
||||
const newViews = existingViews + unique_pageviews;
|
||||
const newUsers = existingUsers + users;
|
||||
|
||||
pageData[month_of_year] = {
|
||||
views: newViews,
|
||||
users: newUsers,
|
||||
};
|
||||
|
||||
results[page] = pageData;
|
||||
|
||||
pageSummary[page] = pageSummary[page] || { views: 0, users: 0 };
|
||||
pageSummary[page].views += unique_pageviews;
|
||||
pageSummary[page].users += users;
|
||||
})
|
||||
.on('end', () => {
|
||||
const csvHeader = [
|
||||
'Page',
|
||||
'Jan 2022',
|
||||
'Feb 2022',
|
||||
'Mar 2022',
|
||||
'Apr 2022',
|
||||
'May 2022',
|
||||
'Jun 2022',
|
||||
'Jul 2022',
|
||||
'Aug 2022',
|
||||
'Sep 2022',
|
||||
'Oct 2022',
|
||||
'Nov 2022',
|
||||
'Dec 2022',
|
||||
'Jan 2023',
|
||||
'Feb 2023',
|
||||
'Mar 2023',
|
||||
'Apr 2023',
|
||||
'May 2023',
|
||||
'Jun 2023',
|
||||
'Jul 2023',
|
||||
'Aug 2023',
|
||||
'Sep 2023',
|
||||
'Oct 2023',
|
||||
'Nov 2023',
|
||||
'Dec 2023',
|
||||
];
|
||||
|
||||
const csvRows = Object.keys(pageSummary)
|
||||
.filter(pageUrl => pageSummary[pageUrl].views > 10)
|
||||
.filter(pageUrl => !['/upcoming', '/pdfs', '/signup', '/login', '/@'].includes(pageUrl))
|
||||
.sort((pageA, pageB) => {
|
||||
const aViews = pageSummary[pageA].views;
|
||||
const bViews = pageSummary[pageB].views;
|
||||
|
||||
return bViews - aViews;
|
||||
})
|
||||
.map((pageUrl) => {
|
||||
const rawPageResult = results[pageUrl];
|
||||
const pageResultCsvRow = [];
|
||||
|
||||
csvHeader.forEach((csvHeaderItem) => {
|
||||
if (csvHeaderItem === 'Page') {
|
||||
pageResultCsvRow.push(pageUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const csvHeaderItemAlt = csvHeaderItem
|
||||
.replace(/ /g, '_')
|
||||
.toLowerCase();
|
||||
|
||||
const result = rawPageResult[csvHeaderItem || csvHeaderItemAlt] || {};
|
||||
const views = result.views || 0;
|
||||
const users = result.users || 0;
|
||||
|
||||
pageResultCsvRow.push(users);
|
||||
});
|
||||
|
||||
return pageResultCsvRow;
|
||||
});
|
||||
|
||||
const finalCsvRows = [csvHeader, ...csvRows];
|
||||
const csvRowStrings = finalCsvRows.map((row) => {
|
||||
return row.join(',');
|
||||
});
|
||||
|
||||
const csvString = csvRowStrings.join('\n');
|
||||
fs.writeFileSync(path.join(__dirname, '../data-agg.csv'), csvString);
|
||||
});
|
||||
@@ -3,7 +3,6 @@ const path = require('path');
|
||||
|
||||
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
|
||||
const ALL_ROADMAPS_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const ROADMAP_JSON_DIR = path.join(__dirname, '../public/jsons/roadmaps');
|
||||
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
@@ -61,12 +60,12 @@ function writeTopicContent(currTopicUrl) {
|
||||
|
||||
const roadmapTitle = roadmapId.replace(/-/g, ' ');
|
||||
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
if (!childTopic) {
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
}
|
||||
|
||||
console.log(`Genearting '${childTopic || parentTopic}'...`);
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai
|
||||
@@ -90,10 +89,60 @@ function writeTopicContent(currTopicUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
|
||||
if (!currTopicUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
|
||||
|
||||
if (!contentFilePath) {
|
||||
console.log(`Missing file for: ${currTopicUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
|
||||
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
|
||||
|
||||
if (!isFileEmpty) {
|
||||
console.log(`Ignoring ${topicId}. Not empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let newFileContent = `# ${topicTitle}`;
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log(`Writing ${topicId}..`);
|
||||
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(currTopicUrl);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
// console.log(topicTitle);
|
||||
// console.log(topicUrlToPathMapping[currTopicUrl]);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
|
||||
|
||||
const roadmapJson = require(path.join(ROADMAP_JSON_DIR, `${roadmapId}.json`));
|
||||
const roadmapJson = require(path.join(
|
||||
ALL_ROADMAPS_DIR,
|
||||
`${roadmapId}/${roadmapId}`
|
||||
));
|
||||
|
||||
const groups = roadmapJson?.mockup?.controls?.control?.filter(
|
||||
(control) =>
|
||||
control.typeID === '__group__' &&
|
||||
@@ -106,50 +155,13 @@ async function run() {
|
||||
console.log('----------------------------------------');
|
||||
}
|
||||
|
||||
const writePromises = [];
|
||||
for (let group of groups) {
|
||||
const topicId = group?.properties?.controlName;
|
||||
const topicTitle = group?.children?.controls?.control?.find(
|
||||
(control) => control?.typeID === 'Label'
|
||||
)?.properties?.text;
|
||||
const currTopicUrl = topicId?.replace(/^\d+-/g, '/')?.replace(/:/g, '/');
|
||||
if (!currTopicUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contentFilePath = topicUrlToPathMapping[currTopicUrl];
|
||||
|
||||
if (!contentFilePath) {
|
||||
console.log(`Missing file for: ${currTopicUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFileContent = fs.readFileSync(contentFilePath, 'utf8');
|
||||
const isFileEmpty = currentFileContent.replace(/^#.+/, ``).trim() === '';
|
||||
|
||||
if (!isFileEmpty) {
|
||||
console.log(`Ignoring ${topicId}. Not empty.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let newFileContent = `# ${topicTitle}`;
|
||||
|
||||
if (!OPEN_AI_API_KEY) {
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
continue;
|
||||
}
|
||||
|
||||
const topicContent = await writeTopicContent(currTopicUrl);
|
||||
newFileContent += `\n\n${topicContent}`;
|
||||
|
||||
console.log(`Writing ${topicId}..`);
|
||||
fs.writeFileSync(contentFilePath, newFileContent, 'utf8');
|
||||
|
||||
// console.log(currentFileContent);
|
||||
// console.log(currTopicUrl);
|
||||
// console.log(topicTitle);
|
||||
// console.log(topicUrlToPathMapping[currTopicUrl]);
|
||||
writePromises.push(writeFileForGroup(group, topicUrlToPathMapping));
|
||||
}
|
||||
|
||||
console.log('Waiting for all files to be written...');
|
||||
await Promise.all(writePromises);
|
||||
}
|
||||
|
||||
run()
|
||||
@@ -84,8 +84,9 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
|
||||
|
||||
const roadmap = require(path.join(
|
||||
__dirname,
|
||||
`../public/jsons/roadmaps/${roadmapId}`
|
||||
`../src/data/roadmaps/${roadmapId}/${roadmapId}`
|
||||
));
|
||||
|
||||
const controls = roadmap.mockup.controls.control;
|
||||
|
||||
// Prepare the dir tree that we will be creating and also calculate the sort orders
|
||||
@@ -22,7 +22,7 @@ function removeAllSponsors(baseContentDir) {
|
||||
|
||||
const contentDir = fs.readdirSync(contentDirPath);
|
||||
contentDir.forEach((content) => {
|
||||
console.log('Removing sponsor from: ', content);
|
||||
console.log('Removing sponsors from: ', content);
|
||||
|
||||
const pageFilePath = path.join(contentDirPath, content, `${content}.md`);
|
||||
const pageFileContent = fs.readFileSync(pageFilePath, 'utf8');
|
||||
@@ -35,7 +35,7 @@ function removeAllSponsors(baseContentDir) {
|
||||
.trim();
|
||||
|
||||
let frontmatterObj = yaml.load(existingFrontmatter);
|
||||
delete frontmatterObj.sponsor;
|
||||
delete frontmatterObj.sponsors;
|
||||
|
||||
const newFrontmatter = yaml.dump(frontmatterObj, {
|
||||
lineWidth: 10000,
|
||||
@@ -87,27 +87,23 @@ function addPageSponsor({
|
||||
.trim();
|
||||
|
||||
let frontmatterObj = yaml.load(existingFrontmatter);
|
||||
delete frontmatterObj.sponsor;
|
||||
const sponsors = frontmatterObj.sponsors || [];
|
||||
|
||||
const frontmatterValues = Object.entries(frontmatterObj);
|
||||
const roadmapLabel = frontmatterObj.briefTitle;
|
||||
|
||||
sponsors.push({
|
||||
url: redirectUrl,
|
||||
title: adTitle,
|
||||
imageUrl,
|
||||
description: adDescription,
|
||||
page: roadmapLabel,
|
||||
company,
|
||||
});
|
||||
|
||||
// Insert sponsor data at 10 index i.e. after
|
||||
// roadmap dimensions in the frontmatter
|
||||
frontmatterValues.splice(10, 0, [
|
||||
'sponsor',
|
||||
{
|
||||
url: redirectUrl,
|
||||
title: adTitle,
|
||||
imageUrl,
|
||||
description: adDescription,
|
||||
event: {
|
||||
category: 'SponsorClick',
|
||||
action: `${company} Redirect`,
|
||||
label: `${roadmapLabel} / ${company} Link`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
frontmatterValues.splice(10, 0, ['sponsors', sponsors]);
|
||||
|
||||
frontmatterObj = Object.fromEntries(frontmatterValues);
|
||||
|
||||
153
src/components/AccountSidebar.astro
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
|
||||
const { activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
activePageTitle: string;
|
||||
}
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
href: '/account',
|
||||
title: 'Activity',
|
||||
id: 'activity',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'analytics',
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
id: 'road-card',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'badge',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-profile',
|
||||
title: 'Profile',
|
||||
id: 'profile',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-password',
|
||||
title: 'Security',
|
||||
id: 'change-password',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'security',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class='relative mb-5 block border-b p-4 shadow-inner md:hidden'>
|
||||
<button
|
||||
class='flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-sm font-medium text-gray-900'
|
||||
id='settings-menu'
|
||||
>
|
||||
{activePageTitle}
|
||||
<AstroIcon icon='dropdown' />
|
||||
</button>
|
||||
<ul
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
|
||||
>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='container flex min-h-screen items-stretch'>
|
||||
<!-- Start Desktop Sidebar -->
|
||||
<aside class='hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div class='grow px-0 py-0 md:px-10 md:py-10'>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const menuButton = document.getElementById('settings-menu');
|
||||
const menuDropdown = document.getElementById('settings-menu-dropdown');
|
||||
|
||||
menuButton?.addEventListener('click', () => {
|
||||
menuDropdown?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton?.contains(e.target as Node)) {
|
||||
menuDropdown?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
56
src/components/Activity/ActivityCounters.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
type ActivityCountersType = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ActivityCounterType = {
|
||||
text: string;
|
||||
count: string;
|
||||
};
|
||||
|
||||
function ActivityCounter(props: ActivityCounterType) {
|
||||
const { text, count } = props;
|
||||
|
||||
return (
|
||||
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
|
||||
<h2 class="text-base sm:text-5xl font-bold">
|
||||
{count}
|
||||
</h2>
|
||||
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityCounters(props: ActivityCountersType) {
|
||||
const { done, learning, streak } = props;
|
||||
|
||||
return (
|
||||
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
|
||||
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
|
||||
<ActivityCounter
|
||||
text={'Topics Completed'}
|
||||
count={`${done?.total || 0}`}
|
||||
/>
|
||||
|
||||
<ActivityCounter
|
||||
text={'Currently Learning'}
|
||||
count={`${learning?.total || 0}`}
|
||||
/>
|
||||
|
||||
<ActivityCounter
|
||||
text={'Visit Streak'}
|
||||
count={`${streak?.count || 0}d`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/components/Activity/ActivityPage.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ActivityCounters } from './ActivityCounters';
|
||||
import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
roadmaps: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
total: number;
|
||||
skipped: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
bestPractices: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
firstVisitAt: Date | null;
|
||||
lastVisitAt: Date | null;
|
||||
};
|
||||
activity: {
|
||||
type: 'done' | 'learning' | 'pending' | 'skipped';
|
||||
createdAt: Date;
|
||||
metadata: {
|
||||
resourceId?: string;
|
||||
resourceType?: 'roadmap' | 'best-practice';
|
||||
topicId?: string;
|
||||
topicLabel?: string;
|
||||
resourceTitle?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export function ActivityPage() {
|
||||
const [activity, setActivity] = useState<ActivityResponse>();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadActivity() {
|
||||
const { error, response } = await httpGet<ActivityResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-stats`
|
||||
);
|
||||
|
||||
if (!response || error) {
|
||||
console.error('Error loading activity');
|
||||
console.error(error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setActivity(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const learningRoadmaps = activity?.learning.roadmaps || [];
|
||||
const learningBestPractices = activity?.learning.bestPractices || [];
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityCounters
|
||||
done={activity?.done || { today: 0, total: 0 }}
|
||||
learning={activity?.learning || { today: 0, total: 0 }}
|
||||
streak={activity?.streak || { count: 0 }}
|
||||
/>
|
||||
|
||||
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
{learningRoadmaps.length === 0 &&
|
||||
learningBestPractices.length === 0 && <EmptyActivity />}
|
||||
|
||||
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
|
||||
<>
|
||||
<h2 class="mb-3 text-xs uppercase text-gray-400">
|
||||
Continue Following
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
const updatedAtB = new Date(b.updatedAt);
|
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
skippedCount={roadmap.skipped || 0}
|
||||
resourceId={roadmap.id}
|
||||
resourceType={'roadmap'}
|
||||
updatedAt={roadmap.updatedAt}
|
||||
title={roadmap.title}
|
||||
onCleared={() => {
|
||||
pageProgressMessage.set('Updating activity');
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{learningBestPractices
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
const updatedAtB = new Date(b.updatedAt);
|
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
||||
})
|
||||
.map((bestPractice) => (
|
||||
<ResourceProgress
|
||||
doneCount={bestPractice.done || 0}
|
||||
totalCount={bestPractice.total || 0}
|
||||
learningCount={bestPractice.learning || 0}
|
||||
resourceId={bestPractice.id}
|
||||
skippedCount={bestPractice.skipped || 0}
|
||||
resourceType={'best-practice'}
|
||||
title={bestPractice.title}
|
||||
updatedAt={bestPractice.updatedAt}
|
||||
onCleared={() => {
|
||||
pageProgressMessage.set('Updating activity');
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/Activity/EmptyActivity.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
|
||||
export function EmptyActivity() {
|
||||
return (
|
||||
<div class="rounded-md">
|
||||
<div class="flex flex-col items-center p-7 text-center">
|
||||
<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>
|
||||
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
|
||||
Progress will appear here as you start tracking your{' '}
|
||||
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
|
||||
Best Practices
|
||||
</a>{' '}
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/Activity/ResourceProgress.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
totalCount: number;
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
onCleared: () => void;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const {
|
||||
updatedAt,
|
||||
resourceType,
|
||||
resourceId,
|
||||
title,
|
||||
totalCount,
|
||||
learningCount,
|
||||
doneCount,
|
||||
skippedCount,
|
||||
onCleared,
|
||||
} = props;
|
||||
|
||||
async function clearProgress() {
|
||||
setIsClearing(true);
|
||||
const { error, response } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
alert('Error clearing progress. Please try again.');
|
||||
console.error(error);
|
||||
setIsClearing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
console.log(`${resourceType}-${resourceId}-progress`);
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
onCleared();
|
||||
}
|
||||
|
||||
const url =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}`
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
href={url}
|
||||
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
<span className="relative flex-1 cursor-pointer truncate">
|
||||
{title}
|
||||
</span>
|
||||
<span className="ml-1 cursor-pointer text-sm text-gray-400">
|
||||
{getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</a>
|
||||
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<span className="hidden flex-1 gap-1 sm:flex">
|
||||
{doneCount > 0 && (
|
||||
<>
|
||||
<span>{doneCount} done</span> •
|
||||
</>
|
||||
)}
|
||||
{learningCount > 0 && (
|
||||
<>
|
||||
<span>{learningCount} in progress</span> •
|
||||
</>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<>
|
||||
<span>{skippedCount} skipped</span> •
|
||||
</>
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,17 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src='./analytics.js'></script>
|
||||
<script src='./analytics.ts'></script>
|
||||
<script async src='https://www.googletagmanager.com/gtag/js?id=UA-139582634-1'
|
||||
></script>
|
||||
<script is:inline>
|
||||
// @ts-nocheck
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-139582634-1');
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
let trackEl = e.target;
|
||||
if (!trackEl.getAttribute('ga-category')) {
|
||||
trackEl = trackEl.closest('[ga-category]');
|
||||
}
|
||||
|
||||
if (!trackEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const category = trackEl.getAttribute('ga-category');
|
||||
const action = trackEl.getAttribute('ga-action');
|
||||
const label = trackEl.getAttribute('ga-label');
|
||||
|
||||
if (!category) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.fireEvent({
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// To selectively enable/disable debug logs
|
||||
__DEBUG__: boolean;
|
||||
gtag: any;
|
||||
fireEvent: (props: GAEventType) => void;
|
||||
fireEvent: (props: {
|
||||
action: string;
|
||||
category: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export type GAEventType = {
|
||||
action: string;
|
||||
category: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the event on google analytics
|
||||
* @see https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
* @param props Event properties
|
||||
* @returns void
|
||||
*/
|
||||
window.fireEvent = (props: GAEventType) => {
|
||||
window.fireEvent = (props) => {
|
||||
const { action, category, label, value } = props;
|
||||
if (!window.gtag) {
|
||||
console.warn('Missing GTAG - Analytics disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.__DEBUG__) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('Analytics event fired', props);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,5 +35,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
|
||||
const svgAttributes = { ...baseAttributes, ...attributes };
|
||||
---
|
||||
|
||||
|
||||
<svg {...svgAttributes} set:html={innerHTML}></svg>
|
||||
<svg {...svgAttributes} set:html={innerHTML}></svg>
|
||||
5
src/components/AuthenticationFlow/Divider.astro
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class='flex w-full items-center gap-2 py-6 text-sm text-slate-600'>
|
||||
<div class='h-px w-full bg-slate-200'></div>
|
||||
OR
|
||||
<div class='h-px w-full bg-slate-200'></div>
|
||||
</div>
|
||||
103
src/components/AuthenticationFlow/EmailLoginForm.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleFormSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-login`,
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
);
|
||||
|
||||
// Log the user in and reload the page
|
||||
if (response?.token) {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.reload();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// @todo use proper types
|
||||
if ((error as any).type === 'user_not_verified') {
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong. Please try again later.');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full" onSubmit={handleFormSubmit}>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
<p class="mb-3 mt-2 text-sm text-gray-500">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-blue-800 hover:text-blue-600"
|
||||
>
|
||||
Reset your password?
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mb-2 rounded-md bg-red-100 p-2 text-red-800">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLoginForm;
|
||||
103
src/components/AuthenticationFlow/EmailSignupForm.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
const EmailSignupForm: FunctionComponent = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ status: 'ok' }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-register`,
|
||||
{
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || response?.status !== 'ok') {
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
|
||||
<label htmlFor="name" className="sr-only">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
min={3}
|
||||
max={50}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Full Name"
|
||||
value={name}
|
||||
onInput={(e) => setName(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
min={6}
|
||||
max={50}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-100 p-2 text-red-700">{error}.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSignupForm;
|
||||
64
src/components/AuthenticationFlow/ForgotPasswordForm.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-forgot-password`,
|
||||
{
|
||||
email,
|
||||
}
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setEmail('');
|
||||
setSuccess('Check your email for a link to reset your password.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="w-full">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p className="mt-2 rounded-lg bg-green-100 p-2 text-sm text-green-700">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
119
src/components/AuthenticationFlow/GitHubButton.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import GitHubIcon from '../../icons/github.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type GitHubButtonProps = {};
|
||||
|
||||
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
||||
const GITHUB_LAST_PAGE = 'githubLastPage';
|
||||
|
||||
export function GitHubButton(props: GitHubButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GitHubIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'github') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
const errMessage = error?.message || 'Something went wrong.';
|
||||
setError(errMessage);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT);
|
||||
const lastPageBeforeGithub = localStorage.getItem(GITHUB_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (gitHubRedirectAt && lastPageBeforeGithub) {
|
||||
const socialRedirectAtTime = parseInt(gitHubRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeGithub;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response, error } = await httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
|
||||
);
|
||||
|
||||
if (error || !response?.loginUrl) {
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="GitHub"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
src/components/AuthenticationFlow/GoogleButton.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import GoogleIcon from '../../icons/google.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type GoogleButtonProps = {};
|
||||
|
||||
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
||||
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
||||
|
||||
export function GoogleButton(props: GoogleButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GoogleIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'google') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT);
|
||||
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (googleRedirectAt && lastPageBeforeGoogle) {
|
||||
const socialRedirectAtTime = parseInt(googleRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeGoogle;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with Google
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
src/components/AuthenticationFlow/LinkedInButton.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type LinkedInButtonProps = {};
|
||||
|
||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||
|
||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : LinkedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'linkedin') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
|
||||
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (linkedInRedirectAt && lastPageBeforeLinkedIn) {
|
||||
const socialRedirectAtTime = parseInt(linkedInRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeLinkedIn;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, window.location.pathname);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
src/components/AuthenticationFlow/LoginPopup.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
</h2>
|
||||
<p class='mt-2 text-sm leading-4 text-slate-600'>
|
||||
You must be logged in to perform this action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
<LinkedInButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<EmailLoginForm client:load />
|
||||
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Don't have an account?{' '}
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
|
||||
</div>
|
||||
</Popup>
|
||||
100
src/components/AuthenticationFlow/ResetPasswordForm.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
setCode(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
setIsLoading(false);
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-reset-forgotten-password`,
|
||||
{
|
||||
newPassword: password,
|
||||
confirmPassword: passwordConfirm,
|
||||
code,
|
||||
}
|
||||
);
|
||||
|
||||
if (error?.message) {
|
||||
setIsLoading(false);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response?.token) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = response.token;
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mx-auto w-full" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
className="mb-2 mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Confirm New Password"
|
||||
value={passwordConfirm}
|
||||
onInput={(e) =>
|
||||
setPasswordConfirm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
82
src/components/AuthenticationFlow/TriggerVerifyAccount.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import ErrorIcon from '../../icons/error.svg';
|
||||
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function TriggerVerifyAccount() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const triggerVerify = (code: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
httpPost<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
|
||||
{
|
||||
code,
|
||||
}
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again.');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code')!;
|
||||
|
||||
if (!code) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
triggerVerify(code);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
{isLoading && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={SpinnerIcon}
|
||||
class={'mx-auto h-16 w-16 animate-spin'}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={ErrorIcon}
|
||||
className={'mx-auto h-16 w-16'}
|
||||
/>
|
||||
)}
|
||||
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
|
||||
Verifying your account
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
{isLoading && <p>Please wait while we verify your account..</p>}
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import VerifyLetterIcon from '../../icons/verify-letter.svg';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function VerificationEmailMessage() {
|
||||
const [email, setEmail] = useState('..');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEmailResent, setIsEmailResent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
setEmail(urlParams.get('email')!);
|
||||
}, []);
|
||||
|
||||
const resendVerificationEmail = () => {
|
||||
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-send-verification-email`, {
|
||||
email,
|
||||
})
|
||||
.then(({ response, error }) => {
|
||||
if (error) {
|
||||
setIsEmailResent(false);
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEmailResent(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsEmailResent(false);
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<img
|
||||
alt="Verify Email"
|
||||
src={VerifyLetterIcon}
|
||||
class="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
/>
|
||||
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
Verify your email address
|
||||
</h2>
|
||||
<div class="text-sm sm:text-base">
|
||||
<p>
|
||||
We have sent you an email at{' '}
|
||||
<span className="font-bold">{email}</span>. Please click the link to
|
||||
verify your account. This link will expire shortly, so please verify
|
||||
soon!
|
||||
</p>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
{!isEmailResent && (
|
||||
<>
|
||||
{isLoading && <p className="text-gray-400">Sending the email ..</p>}
|
||||
{!isLoading && !error && (
|
||||
<p>
|
||||
Please make sure to check your spam folder. If you still don't
|
||||
have the email click to{' '}
|
||||
<button
|
||||
disabled={!email}
|
||||
className="inline text-blue-700"
|
||||
onClick={resendVerificationEmail}
|
||||
>
|
||||
resend verification email.
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEmailResent && (
|
||||
<p class="text-green-700">Verification email has been sent!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/Authenticator/Authenticator.astro
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src='./authenticator.ts'></script>
|
||||
81
src/components/Authenticator/authenticator.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
function easeInElement(el: Element) {
|
||||
el.classList.add('opacity-0', 'transition-opacity', 'duration-300');
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('opacity-0');
|
||||
});
|
||||
}
|
||||
|
||||
function showHideAuthElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
document.querySelectorAll('[data-auth-required]').forEach((el) => {
|
||||
if (hideOrShow === 'hide') {
|
||||
el.classList.add('hidden');
|
||||
} else {
|
||||
easeInElement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
document.querySelectorAll('[data-guest-required]').forEach((el) => {
|
||||
if (hideOrShow === 'hide') {
|
||||
el.classList.add('hidden');
|
||||
} else {
|
||||
easeInElement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prepares the UI for the user who is logged in
|
||||
function handleGuest() {
|
||||
const authenticatedRoutes = [
|
||||
'/account/update-profile',
|
||||
'/account/update-password',
|
||||
'/account/road-card',
|
||||
'/account',
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
showHideGuestElements('show');
|
||||
|
||||
// If the user is on an authenticated route, redirect them to the home page
|
||||
if (authenticatedRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Prepares the UI for the user who is logged out
|
||||
function handleAuthenticated() {
|
||||
const guestRoutes = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/verify-account',
|
||||
'/verification-pending',
|
||||
'/reset-password',
|
||||
'/forgot-password',
|
||||
];
|
||||
|
||||
showHideGuestElements('hide');
|
||||
showHideAuthElements('show');
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAuthRequired() {
|
||||
const token = Cookies.get(TOKEN_COOKIE_NAME);
|
||||
if (token) {
|
||||
handleAuthenticated();
|
||||
} else {
|
||||
handleGuest();
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
handleAuthRequired();
|
||||
}, 0);
|
||||
@@ -1,8 +1,7 @@
|
||||
---
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import BestPracticeHint from './BestPracticeHint.astro';
|
||||
import DownloadPopup from './DownloadPopup.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import SubscribePopup from './SubscribePopup.astro';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -15,23 +14,22 @@ const { title, description, bestPracticeId, isUpcoming = false } = Astro.props;
|
||||
const isBestPracticeReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<DownloadPopup />
|
||||
<SubscribePopup />
|
||||
<LoginPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='py-5 sm:py-12 container relative'>
|
||||
<div class='mt-0 mb-3 sm:mb-6'>
|
||||
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<div class='mb-3 mt-0 sm:mb-6'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
{title}
|
||||
</h1>
|
||||
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
|
||||
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between'>
|
||||
<div class='flex gap-1 sm:gap-2'>
|
||||
<a
|
||||
href='/best-practices'
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to All Best Practices'
|
||||
>
|
||||
←<span class='hidden sm:inline'> All Best Practices</span>
|
||||
@@ -40,26 +38,37 @@ const isBestPracticeReady = !isUpcoming;
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<button
|
||||
data-popup='download-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
aria-label='Download Best Practice'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Download Best Practice Popup'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='hidden inline-flex items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='hidden sm:inline ml-2'>Download</span>
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
data-auth-required
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
target="_blank"
|
||||
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
data-popup='subscribe-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Subscribe for Updates'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Subscribe Best Practice Popup'
|
||||
>
|
||||
<Icon icon='email' />
|
||||
<span class='ml-2'>Subscribe</span>
|
||||
@@ -71,7 +80,7 @@ const isBestPracticeReady = !isUpcoming;
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
|
||||
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Suggest Changes'
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
---
|
||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
export interface Props {
|
||||
bestPracticeId: string;
|
||||
}
|
||||
---
|
||||
|
||||
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
<div class='hidden sm:flex justify-between px-2 bg-white items-center rounded-md p-1.5'>
|
||||
<p class='text-sm'>
|
||||
<span class='text-yellow-900 bg-yellow-200 py-0.5 px-1 text-xs rounded-sm font-medium uppercase mr-0.5'>Tip</span>
|
||||
Click the best practices for details and resources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mobile - Roadmap resources alert -->
|
||||
<p class='block sm:hidden text-sm border border-yellow-500 text-yellow-700 rounded-md py-1.5 px-2 bg-white relative'>
|
||||
Click the best practices for details and resources
|
||||
</p>
|
||||
<ResourceProgressStats />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const { breadcrumbs, roadmapId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='py-7 pb-6'>
|
||||
<!-- Desktop breadcrums -->
|
||||
<!-- Desktop breadcrumbs -->
|
||||
<p class='text-gray-500 container hidden sm:block'>
|
||||
{
|
||||
breadcrumbs.map((breadcrumb, counter) => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
---
|
||||
|
||||
<div class='recaptcha-field mb-2'></div>
|
||||
<input type='hidden' name='g-recaptcha-response' class='recaptcha-response' />
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script src='./captcha.js'></script>
|
||||
|
||||
<script is:inline>
|
||||
window.onCaptchaLoad = function () {
|
||||
if (!window.grecaptcha) {
|
||||
console.warn('window.grecaptcha is not defined');
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaFields = document.querySelectorAll('.recaptcha-field');
|
||||
|
||||
// render recaptcha on fields
|
||||
recaptchaFields.forEach((field) => {
|
||||
// If captcha already rendered for this field
|
||||
if (field.hasAttribute('data-recaptcha-id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedId = window.grecaptcha.render(field, {
|
||||
sitekey: '6Ldn2YsjAAAAABlUxNxukAuDAUIuZIhO0hRVxzJW',
|
||||
});
|
||||
|
||||
field.setAttribute('data-recaptcha-id', renderedId);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<script
|
||||
src='https://www.google.com/recaptcha/api.js?onload=onCaptchaLoad&render=explicit'
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
@@ -1,49 +0,0 @@
|
||||
class Captcha {
|
||||
constructor() {
|
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this);
|
||||
this.bindValidation = this.bindValidation.bind(this);
|
||||
this.validateCaptchaBeforeSubmit =
|
||||
this.validateCaptchaBeforeSubmit.bind(this);
|
||||
}
|
||||
|
||||
validateCaptchaBeforeSubmit(e) {
|
||||
const target = e.target;
|
||||
const captchaField = target.querySelector('.recaptcha-field');
|
||||
|
||||
if (captchaField) {
|
||||
const captchaId = captchaField.dataset.recaptchaId;
|
||||
const captchaResponse = window.grecaptcha.getResponse(captchaId);
|
||||
|
||||
// If valid captcha is not present, prevent form submission
|
||||
if (!captchaResponse) {
|
||||
e.preventDefault();
|
||||
alert('Please verify that you are human first');
|
||||
return false;
|
||||
}
|
||||
|
||||
target.querySelector('.recaptcha-response').value = captchaResponse;
|
||||
}
|
||||
|
||||
target.closest('.popup').classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
bindValidation() {
|
||||
const forms = document.querySelectorAll('[captcha-form]');
|
||||
|
||||
forms.forEach((form) => {
|
||||
form.addEventListener('submit', this.validateCaptchaBeforeSubmit);
|
||||
});
|
||||
}
|
||||
|
||||
onDOMLoaded() {
|
||||
this.bindValidation();
|
||||
}
|
||||
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
const captcha = new Captcha();
|
||||
captcha.init();
|
||||
204
src/components/CommandMenu/CommandMenu.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||
import GuideIcon from '../../icons/guide.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
type PageType = {
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
|
||||
{
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon,
|
||||
},
|
||||
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
|
||||
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
|
||||
];
|
||||
|
||||
function shouldShowPage(page: PageType) {
|
||||
const isUser = isLoggedIn();
|
||||
|
||||
return !page.isProtected || isUser;
|
||||
}
|
||||
|
||||
export function CommandMenu() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLInputElement>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [allPages, setAllPages] = useState<PageType[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(defaultPages);
|
||||
const [searchedText, setSearchedText] = useState('');
|
||||
const [activeCounter, setActiveCounter] = useState(0);
|
||||
|
||||
useKeydown('mod_k', () => {
|
||||
setIsActive(true);
|
||||
});
|
||||
|
||||
useOutsideClick(modalRef, () => {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleToggleTopic(e: any) {
|
||||
setIsActive(true);
|
||||
}
|
||||
|
||||
getAllPages();
|
||||
window.addEventListener(`command.k`, handleToggleTopic);
|
||||
return () => {
|
||||
window.removeEventListener(`command.k`, handleToggleTopic);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current.focus();
|
||||
}, [isActive]);
|
||||
|
||||
async function getAllPages() {
|
||||
if (allPages.length > 0) {
|
||||
return allPages;
|
||||
}
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
if (!response) {
|
||||
return defaultPages.filter(shouldShowPage);
|
||||
}
|
||||
|
||||
setAllPages([...defaultPages, ...response].filter(shouldShowPage));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchedText) {
|
||||
setSearchResults(defaultPages.filter(shouldShowPage));
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearchText = searchedText.trim().toLowerCase();
|
||||
getAllPages().then((unfilteredPages = defaultPages) => {
|
||||
const filteredPages = unfilteredPages
|
||||
.filter((currPage: PageType) => {
|
||||
return (
|
||||
currPage.title.toLowerCase().indexOf(normalizedSearchText) !== -1
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
|
||||
setActiveCounter(0);
|
||||
setSearchResults(filteredPages);
|
||||
});
|
||||
}, [searchedText]);
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
|
||||
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autofocus={true}
|
||||
type="text"
|
||||
value={searchedText}
|
||||
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
|
||||
placeholder="Search roadmaps, guides or pages .."
|
||||
autocomplete="off"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
setSearchedText(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const canGoNext = activeCounter < searchResults.length - 1;
|
||||
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
const activePage = searchResults[activeCounter];
|
||||
if (activePage) {
|
||||
window.location.href = activePage.url;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="px-2 py-2">
|
||||
<div className="flex flex-col">
|
||||
{searchResults.length === 0 && (
|
||||
<div class="p-5 text-center text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.map((page, counter) => {
|
||||
const prevPage = searchResults[counter - 1];
|
||||
const groupChanged = prevPage && prevPage.group !== page.group;
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupChanged && (
|
||||
<div class="border-b border-gray-100"></div>
|
||||
)}
|
||||
<a
|
||||
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
href={page.url}
|
||||
>
|
||||
{!page.icon && (
|
||||
<span class="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img src={page.icon} class="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{page.title}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
import Popup from './Popup/Popup.astro';
|
||||
import CaptchaFields from './Captcha/CaptchaFields.astro';
|
||||
---
|
||||
|
||||
<Popup id='download-popup' title='Download' subtitle='Enter your email below to receive the download link.'>
|
||||
<form
|
||||
action='https://news.roadmap.sh/subscribe'
|
||||
method='POST'
|
||||
accept-charset='utf-8'
|
||||
target='_blank'
|
||||
captcha-form
|
||||
>
|
||||
<input type='hidden' name='gdpr' value='true' />
|
||||
|
||||
<input
|
||||
type='email'
|
||||
name='email'
|
||||
id='email'
|
||||
required
|
||||
autofocus
|
||||
class='w-full rounded-md border text-md py-2.5 px-3 mb-2'
|
||||
placeholder='Enter your Email'
|
||||
/>
|
||||
|
||||
<CaptchaFields />
|
||||
|
||||
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
|
||||
<input type='hidden' name='subform' value='yes' />
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
name='submit'
|
||||
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2'
|
||||
submit-download-form
|
||||
>
|
||||
Send Link
|
||||
</button>
|
||||
</form>
|
||||
</Popup>
|
||||
|
||||
<script>
|
||||
document.querySelector('[submit-download-form]')?.addEventListener('click', () => {
|
||||
window.fireEvent({
|
||||
category: 'Subscription',
|
||||
action: 'Submitted Popup Form',
|
||||
label: 'Download Roadmap Popup',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class='text-sm sm:text-base leading-relaxed text-left p-2 sm:p-4 text-md text-gray-800 border-t border-t-gray-300 bg-gray-100 rounded-bl-md rounded-br-md [&>p:not(:last-child)]:mb-3 [&>p>a]:underline [&>p>a]:text-blue-500'>
|
||||
<div class='text-sm sm:text-base leading-relaxed text-left p-2 sm:p-4 text-md text-gray-800 border-t border-t-gray-300 bg-gray-100 rounded-bl-md rounded-br-md [&>p:not(:last-child)]:mb-3 [&>p>a]:underline [&>p>a]:text-blue-700'>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
question: string;
|
||||
|
||||
@@ -13,14 +13,14 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
|
||||
<a
|
||||
class:list={[
|
||||
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100',
|
||||
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100 overflow-hidden',
|
||||
{
|
||||
'opacity-50': isUpcoming,
|
||||
},
|
||||
]}
|
||||
href={url}
|
||||
>
|
||||
<span class='text-slate-400'>
|
||||
<span class='text-slate-400 relative z-20'>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
@@ -47,4 +47,5 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<span data-progress class="z-10 bg-[#172a3a] absolute top-0 left-0 bottom-0 duration-300 transition-[width] w-0"></span>
|
||||
</a>
|
||||
|
||||
@@ -9,13 +9,15 @@ export interface Props {
|
||||
const { featuredItems, heading } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='py-4 sm:py-14 border-b border-b-[#1e293c] relative'>
|
||||
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
|
||||
<div class='container'>
|
||||
<h2 class='hidden sm:flex absolute rounded-lg -top-[17px] left-1/2 -translate-x-1/2 bg-slate-900 py-1 px-3 border border-[#1e293c] text-md text-slate-400 font-regular'>
|
||||
<h2
|
||||
class='text-md font-regular absolute flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 -top-[17px] sm:left-1/2 sm:-translate-x-1/2'
|
||||
>
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
<ul class='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2'>
|
||||
<ul class='grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3'>
|
||||
{
|
||||
featuredItems.map((featuredItem) => (
|
||||
<li>
|
||||
|
||||
@@ -1,58 +1,66 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<div class='py-6 sm:py-16 pb-10 bg-slate-900 text-white'>
|
||||
<div class='bg-slate-900 py-6 pb-10 text-white sm:py-16'>
|
||||
<div class='container'>
|
||||
<p class='text-gray-400 font-medium flex flex-col sm:flex-row gap-0 sm:gap-4 mb-8 sm:mb-16 justify-center'>
|
||||
<p
|
||||
class='mb-8 flex flex-col justify-center gap-0 font-medium text-gray-400 sm:mb-16 sm:flex-row sm:gap-4'
|
||||
>
|
||||
<a
|
||||
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
|
||||
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='/roadmaps'>Roadmaps</a
|
||||
>
|
||||
<a
|
||||
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
|
||||
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='/best-practices'>Best Practices</a
|
||||
>
|
||||
<a
|
||||
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='/guides'>Guides</a
|
||||
>
|
||||
<a
|
||||
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
|
||||
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='/videos'>Videos</a
|
||||
>
|
||||
<a
|
||||
class='transition-colors px-2 py-1.5 border-b border-b-gray-700 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
|
||||
href='/about'>About</a
|
||||
target='_blank'
|
||||
rel='noopener noreferrer nofollow'
|
||||
class='border-b border-b-gray-700 px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='https://cottonbureau.com/people/roadmapsh'>Store</a
|
||||
>
|
||||
<a
|
||||
class='transition-colors px-2 py-1.5 sm:border-b-0 sm:py-0 sm:px-0 hover:text-white'
|
||||
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
|
||||
href='https://youtube.com/theroadmap?sub_confirmation=1'
|
||||
target='_blank'>YouTube</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class='flex flex-col sm:flex-row justify-between gap-12'>
|
||||
<div class='flex flex-col justify-between gap-12 sm:flex-row'>
|
||||
<div class='max-w-[365px]'>
|
||||
<p class='flex items-center text-md'>
|
||||
<p class='text-md flex items-center'>
|
||||
<a
|
||||
class='font-medium text-lg inline-flex items-center text-white transition-colors hover:text-gray-400'
|
||||
class='inline-flex items-center text-lg font-medium text-white transition-colors hover:text-gray-400'
|
||||
href='/'
|
||||
>
|
||||
<Icon icon='logo' />
|
||||
<span class='ml-2'>roadmap.sh</span>
|
||||
</a>
|
||||
<span class='text-gray-400 mx-2'>by</span>
|
||||
<span class='mx-2 text-gray-400'>by</span>
|
||||
<a
|
||||
class='bg-blue-600 text-sm py-1 px-1.5 font-regular hover:bg-blue-700 rounded-md'
|
||||
href='https://twitter.com/intent/user?screen_name=kamranahmedse'
|
||||
class='font-regular rounded-md bg-blue-600 px-1.5 py-1 text-sm hover:bg-blue-700'
|
||||
href='https://twitter.com/intent/user?screen_name=kamrify'
|
||||
target='_blank'
|
||||
>
|
||||
<span class='hidden sm:inline'>@kamranahmedse</span>
|
||||
<span class='hidden sm:inline'>@kamrify</span>
|
||||
<span class='inline sm:hidden'>Kamran Ahmed</span>
|
||||
</a>
|
||||
</p>
|
||||
<p class='text-slate-300/60 my-4'>
|
||||
Community created roadmaps, articles, resources and journeys to help you choose your path and grow in your
|
||||
career.
|
||||
<p class='my-4 text-slate-300/60'>
|
||||
Community created roadmaps, articles, resources and journeys to help
|
||||
you choose your path and grow in your career.
|
||||
</p>
|
||||
<div class='text-gray-400 text-sm'>
|
||||
<div class='text-sm text-gray-400'>
|
||||
<p>
|
||||
© roadmap.sh
|
||||
<span class='mx-1.5'>·</span>
|
||||
@@ -65,46 +73,37 @@ import Icon from './Icon.astro';
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='text-left sm:text-right max-w-[365px]'>
|
||||
<div class='max-w-[365px] text-left sm:text-right'>
|
||||
<a href='https://thenewstack.io' target='_blank'>
|
||||
<img
|
||||
src='/images/tns-sm.png'
|
||||
alt='ThewNewStack'
|
||||
class='my-1.5 mr-auto sm:mr-0 sm:ml-auto'
|
||||
class='my-1.5 mr-auto sm:ml-auto sm:mr-0'
|
||||
width='200'
|
||||
height='24.8'
|
||||
/>
|
||||
</a>
|
||||
<p class='text-slate-300/60 my-4'>
|
||||
The leading DevOps resource for Kubernetes, cloud-native computing, and the latest in at-scale development,
|
||||
deployment, and management.
|
||||
<p class='my-4 text-slate-300/60'>
|
||||
The leading DevOps resource for Kubernetes, cloud-native computing,
|
||||
and the latest in at-scale development, deployment, and management.
|
||||
</p>
|
||||
<div class='text-gray-400 text-sm'>
|
||||
<div class='text-sm text-gray-400'>
|
||||
<p>
|
||||
<a
|
||||
href='https://thenewstack.io/category/devops?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Footer'
|
||||
target='_blank'
|
||||
ga-category='PartnerClick'
|
||||
ga-action='TNS Referral'
|
||||
ga-label='TNS Referral - Footer'
|
||||
class='text-gray-400 hover:text-white'>DevOps</a
|
||||
>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
href='https://thenewstack.io/category/kubernetes?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Footer'
|
||||
target='_blank'
|
||||
ga-category='PartnerClick'
|
||||
ga-action='TNS Referral'
|
||||
ga-label='TNS Referral - Footer'
|
||||
class='text-gray-400 hover:text-white'>Kubernetes</a
|
||||
>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
href='https://thenewstack.io/category/cloud-native?utm_source=roadmap.sh&utm_medium=Referral&utm_campaign=Footer'
|
||||
target='_blank'
|
||||
ga-category='PartnerClick'
|
||||
ga-action='TNS Referral'
|
||||
ga-label='TNS Referral - Footer'
|
||||
class='text-gray-400 hover:text-white'>Cloud-Native</a
|
||||
>
|
||||
</p>
|
||||
|
||||
@@ -5,26 +5,26 @@ import './FrameRenderer.css';
|
||||
export interface Props {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
jsonUrl: string;
|
||||
dimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
|
||||
const { resourceId, resourceType, dimensions = null } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id='resource-svg-wrap'
|
||||
style={dimensions ? `--aspect-ratio:${dimensions.width}/${dimensions.height}` : null}
|
||||
style={dimensions
|
||||
? `--aspect-ratio:${dimensions.width}/${dimensions.height}`
|
||||
: null}
|
||||
data-resource-type={resourceType}
|
||||
data-resource-id={resourceId}
|
||||
data-json-url={jsonUrl}
|
||||
>
|
||||
<div id='resource-loader'>
|
||||
<Loader />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src='./renderer.js'></script>
|
||||
<script src='./renderer.ts'></script>
|
||||
|
||||
@@ -49,10 +49,27 @@ svg .done rect {
|
||||
fill: #cbcbcb !important;
|
||||
}
|
||||
|
||||
svg .done text {
|
||||
svg .done text, svg .skipped text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
svg .learning rect {
|
||||
fill: #dad1fd !important;
|
||||
}
|
||||
|
||||
svg .skipped rect {
|
||||
fill: #496b69!important;
|
||||
}
|
||||
|
||||
svg .learning rect[fill='rgb(51,51,51)'] + text,
|
||||
svg .done rect[fill='rgb(51,51,51)'] + text {
|
||||
fill: black !important;
|
||||
}
|
||||
|
||||
svg .learning text {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
svg .clickable-group.done[data-group-id^='check:'] rect {
|
||||
fill: gray !important;
|
||||
stroke: gray;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
jsonUrl: string;
|
||||
loaderHTML: string | null;
|
||||
|
||||
containerId: string;
|
||||
loaderId: string;
|
||||
|
||||
constructor() {
|
||||
this.resourceId = '';
|
||||
this.resourceType = '';
|
||||
@@ -32,12 +46,11 @@ export class Renderer {
|
||||
}
|
||||
|
||||
// Clone it so we can use it later
|
||||
this.loaderHTML = this.loaderEl.innerHTML;
|
||||
this.loaderHTML = this.loaderEl!.innerHTML;
|
||||
const dataset = this.containerEl.dataset;
|
||||
|
||||
this.resourceType = dataset.resourceType;
|
||||
this.resourceId = dataset.resourceId;
|
||||
this.jsonUrl = dataset.jsonUrl;
|
||||
this.resourceType = dataset.resourceType!;
|
||||
this.resourceId = dataset.resourceId!;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -46,13 +59,17 @@ export class Renderer {
|
||||
* @param { string } jsonUrl
|
||||
* @returns {Promise<SVGElement>}
|
||||
*/
|
||||
jsonToSvg(jsonUrl) {
|
||||
jsonToSvg(jsonUrl: string) {
|
||||
if (!jsonUrl) {
|
||||
console.error('jsonUrl not defined in frontmatter');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.containerEl.innerHTML = this.loaderHTML;
|
||||
if (!this.containerEl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.containerEl.innerHTML = this.loaderHTML!;
|
||||
|
||||
return fetch(jsonUrl)
|
||||
.then((res) => {
|
||||
@@ -64,9 +81,19 @@ export class Renderer {
|
||||
});
|
||||
})
|
||||
.then((svg) => {
|
||||
this.containerEl.replaceChildren(svg);
|
||||
this.containerEl?.replaceChildren(svg);
|
||||
})
|
||||
.then(() => {
|
||||
return renderResourceProgress(
|
||||
this.resourceType as ResourceType,
|
||||
this.resourceId
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!this.containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `
|
||||
<strong>There was an error.</strong><br>
|
||||
|
||||
@@ -74,11 +101,23 @@ export class Renderer {
|
||||
|
||||
${error.message} <br /> ${error.stack}
|
||||
`;
|
||||
|
||||
this.containerEl.innerHTML = `<div class="error py-5 text-center text-red-600 mx-auto">${message}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
trackVisit() {
|
||||
if (!isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-visit`, {
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
}).then(() => null);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
onDOMLoaded() {
|
||||
if (!this.prepareConfig()) {
|
||||
return;
|
||||
@@ -87,45 +126,43 @@ export class Renderer {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const roadmapType = urlParams.get('r');
|
||||
|
||||
this.trackVisit();
|
||||
|
||||
if (roadmapType) {
|
||||
this.switchRoadmap(`/jsons/roadmaps/${roadmapType}.json`);
|
||||
this.switchRoadmap(`/${roadmapType}.json`);
|
||||
} else {
|
||||
this.jsonToSvg(this.jsonUrl);
|
||||
this.jsonToSvg(
|
||||
this.resourceType === 'roadmap'
|
||||
? `/${this.resourceId}.json`
|
||||
: `/best-practices/${this.resourceId}.json`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switchRoadmap(newJsonUrl) {
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop().replace('.json', '');
|
||||
switchRoadmap(newJsonUrl: string) {
|
||||
this.containerEl?.setAttribute('style', '');
|
||||
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
||||
|
||||
// Update the URL and attach the new roadmap type
|
||||
if (window?.history?.pushState) {
|
||||
const url = new URL(window.location);
|
||||
const url = new URL(window.location.href);
|
||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||
|
||||
url.searchParams.delete(type);
|
||||
url.searchParams.set(type, newJsonFileSlug);
|
||||
|
||||
if (newJsonFileSlug !== this.resourceId) {
|
||||
url.searchParams.set(type, newJsonFileSlug!);
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
|
||||
const pageType = this.resourceType.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
|
||||
window.fireEvent({
|
||||
// RoadmapClick, BestPracticesClick, etc
|
||||
category: `${pageType.replace('-', '')}Click`,
|
||||
// roadmap/frontend/switch-version
|
||||
action: `${this.resourceId}/switch-version`,
|
||||
// roadmap/frontend/switch-version
|
||||
label: `${newJsonFileSlug}`,
|
||||
});
|
||||
|
||||
this.jsonToSvg(newJsonUrl).then(() => {
|
||||
this.containerEl.setAttribute('style', '');
|
||||
});
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||
}
|
||||
|
||||
handleSvgClick(e) {
|
||||
const targetGroup = e.target.closest('g') || {};
|
||||
handleSvgClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
@@ -134,7 +171,17 @@ export class Renderer {
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (/^ext_link/.test(groupId)) {
|
||||
window.open(`https://${groupId.replace('ext_link:', '')}`);
|
||||
const externalLink = groupId.replace('ext_link:', '');
|
||||
|
||||
if (!externalLink.startsWith('roadmap.sh')) {
|
||||
window.fireEvent({
|
||||
category: 'RoadmapExternalLink',
|
||||
action: `${this.resourceType} / ${this.resourceId}`,
|
||||
label: externalLink,
|
||||
});
|
||||
}
|
||||
|
||||
window.open(`https://${externalLink}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,6 +214,7 @@ export class Renderer {
|
||||
detail: {
|
||||
topicId: normalizedGroupId,
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -175,6 +223,7 @@ export class Renderer {
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
window.addEventListener('click', this.handleSvgClick);
|
||||
// window.addEventListener('contextmenu', this.handleSvgClick);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const { author } = frontmatter;
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
class='text-blue-400 hover:text-blue-500 hover:underline'
|
||||
href={`https://github.com/kamranahmedse/roadmap.sh/tree/master/src/data/guides/${guide.id}.md`}
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/guides/${guide.id}.md`}
|
||||
target='_blank'>Improve this Guide</a
|
||||
>
|
||||
</p>
|
||||
|
||||
20
src/components/HeroSection/CheckIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
type CheckIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function CheckIcon(props: CheckIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`relative ${additionalClasses}]`}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
23
src/components/HeroSection/EmptyProgress.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CheckIcon } from './CheckIcon';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function EmptyProgress(props: EmptyProgressProps) {
|
||||
const {
|
||||
title = 'Start learning ..',
|
||||
message = 'Your progress and favorite roadmaps will show up here.',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-col items-start sm:items-center justify-center py-6">
|
||||
<h2 className={'mb-1 flex items-center text-lg sm:text-2xl text-gray-200'}>
|
||||
<CheckIcon additionalClasses='mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]' />
|
||||
Start learning ..
|
||||
</h2>
|
||||
<p className={'text-gray-400 text-sm sm:text-base'}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/HeroSection/FavoriteRoadmaps.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ProgressList } from './ProgressList';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
}[];
|
||||
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
progressList.forEach((progress) => {
|
||||
const href =
|
||||
progress.resourceType === 'best-practice'
|
||||
? `/best-practices/${progress.resourceId}`
|
||||
: `/${progress.resourceId}`;
|
||||
const element = document.querySelector(`a[href="${href}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const totalDone = progress.done + progress.skipped;
|
||||
const percentageDone = (totalDone / progress.total) * 100;
|
||||
|
||||
const progressBar: HTMLElement | null =
|
||||
element.querySelector('[data-progress]');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percentageDone}%`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function FavoriteRoadmaps() {
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<UserProgressResponse>([]);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
|
||||
function showProgressContainer() {
|
||||
const heroEl = document.getElementById('hero-text')!;
|
||||
if (!heroEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroEl.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
heroEl.parentElement?.removeChild(heroEl);
|
||||
setIsPreparing(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setContainerOpacity(100);
|
||||
}, 50);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
const { response: progressList, error } =
|
||||
await httpGet<UserProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(progressList);
|
||||
setIsLoading(false);
|
||||
showProgressContainer();
|
||||
|
||||
// render progress on featured items
|
||||
renderProgress(progressList);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProgress = progress.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{isLoading && <EmptyProgress title="Loading progress .." />}
|
||||
{!isLoading && progress.length == 0 && <EmptyProgress />}
|
||||
{!isLoading && progress.length > 0 && (
|
||||
<ProgressList progress={progress} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/HeroSection/HeroSection.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
---
|
||||
|
||||
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
|
||||
<div
|
||||
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
|
||||
id='hero-text'
|
||||
>
|
||||
<h1
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
|
||||
>
|
||||
Developer Roadmaps
|
||||
</h1>
|
||||
|
||||
<p class='hidden px-4 text-lg text-gray-400 sm:block'>
|
||||
<span class='font-medium text-gray-400'>roadmap.sh</span> is a community effort
|
||||
to create roadmaps, guides and other educational content to help guide developers
|
||||
in picking up a path and guide their learnings.
|
||||
</p>
|
||||
|
||||
<p class='text-md block px-0 text-gray-400 sm:hidden'>
|
||||
Community created roadmaps, guides and articles to help developers grow in
|
||||
their career.
|
||||
</p>
|
||||
</div>
|
||||
<FavoriteRoadmaps client:authenticated />
|
||||
</div>
|
||||
46
src/components/HeroSection/ProgressList.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { UserProgressResponse } from './FavoriteRoadmaps';
|
||||
import { CheckIcon } from './CheckIcon';
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
};
|
||||
|
||||
export function ProgressList(props: ProgressListProps) {
|
||||
const { progress } = props;
|
||||
|
||||
return (
|
||||
<div className="relative pt-4 sm:pt-7 pb-12">
|
||||
<p className="mb-4 flex items-center text-sm text-gray-400">
|
||||
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
|
||||
<span className='hidden sm:inline'>Your progress and favorite roadmaps.</span>
|
||||
<span className='inline sm:hidden'>Your progress and favorite roadmaps.</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{progress.map((resource) => {
|
||||
const url =
|
||||
resource.resourceType === 'roadmap'
|
||||
? `/${resource.resourceId}`
|
||||
: `/best-practices/${resource.resourceId}`;
|
||||
|
||||
const percentageDone =
|
||||
((resource.skipped + resource.done) / resource.total) * 100;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
className="relative flex flex-col rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300 overflow-hidden"
|
||||
>
|
||||
<span className='relative z-20'>{resource.resourceTitle}</span>
|
||||
|
||||
<span
|
||||
class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
|
||||
style={{ width: `${percentageDone}%` }}
|
||||
></span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<div class='flex justify-center w-full'>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-4 prose-h2:mb-2 prose-h3:mt-2 prose-img:mt-1'
|
||||
class='prose-xl prose-blockquote:font-normal prose container prose-code:bg-transparent prose-h2:text-3xl prose-h2:mt-10 prose-h2:mb-3 prose-h3:mt-2 prose-img:mt-1'
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 text-white py-5 sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a class='font-medium text-lg flex items-center text-white' href='/'>
|
||||
<Icon icon='logo' />
|
||||
<span class='ml-3'>roadmap.sh</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
<ul class='hidden sm:flex space-x-5'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/best-practices' class='text-gray-400 hover:text-white'>Best Practices</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class='py-2 px-4 text-sm font-regular rounded-full bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white'
|
||||
href='/signup'
|
||||
>
|
||||
Subscribe
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Mobile Navigation Button -->
|
||||
<button class='text-gray-400 hover:text-gray-50 block sm:hidden cursor-pointer' aria-label='Menu' show-mobile-nav>
|
||||
<Icon icon='hamburger' />
|
||||
</button>
|
||||
|
||||
<!-- Mobile Navigation Items -->
|
||||
<div class='fixed top-0 bottom-0 left-0 right-0 z-40 bg-slate-900 items-center flex hidden' mobile-nav>
|
||||
<button
|
||||
close-mobile-nav
|
||||
class='text-gray-400 hover:text-gray-50 block cursor-pointer absolute top-6 right-6'
|
||||
aria-label='Close Menu'
|
||||
>
|
||||
<Icon icon='close' />
|
||||
</button>
|
||||
<ul class='flex flex-col gap-2 md:gap-3 items-center w-full'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-xl md:text-lg hover:text-blue-300'>Roadmaps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/best-practices' class='text-xl md:text-lg hover:text-blue-300'>Best Practices</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='text-xl md:text-lg hover:text-blue-300'>Guides</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='text-xl md:text-lg hover:text-blue-300'>Videos</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/signup' class='text-xl md:text-lg text-red-300 hover:text-red-400'>Subscribe</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelector('[show-mobile-nav]')?.addEventListener('click', () => {
|
||||
document.querySelector('[mobile-nav]')?.classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.querySelector('[close-mobile-nav]')?.addEventListener('click', () => {
|
||||
document.querySelector('[mobile-nav]')?.classList.add('hidden');
|
||||
});
|
||||
</script>
|
||||
44
src/components/Navigation/AccountDropdown.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
---
|
||||
|
||||
<div class='relative hidden' data-auth-required>
|
||||
<button
|
||||
class='flex h-8 w-28 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600'
|
||||
type='button'
|
||||
data-account-button
|
||||
>
|
||||
<span class='inline-flex items-center gap-1.5'>
|
||||
Account
|
||||
<Icon
|
||||
icon='chevron-down'
|
||||
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
data-account-dropdown
|
||||
>
|
||||
<ul>
|
||||
<li class='px-1'>
|
||||
<a
|
||||
href='/account'
|
||||
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
>
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li class='px-1'>
|
||||
<button
|
||||
class='block w-full rounded px-4 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
type='button'
|
||||
data-logout-button
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
142
src/components/Navigation/Navigation.astro
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import AccountDropdown from './AccountDropdown.astro';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a
|
||||
class='flex items-center text-lg font-medium text-white'
|
||||
href='/'
|
||||
aria-label='roadmap.sh'
|
||||
>
|
||||
<Icon icon='logo' />
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
<ul class='hidden space-x-5 sm:flex sm:items-center'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/best-practices' class='text-gray-400 hover:text-white'
|
||||
>Best Practices</a
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
|
||||
</li>
|
||||
<li>
|
||||
<kbd
|
||||
data-command-menu
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 sm:flex'
|
||||
>
|
||||
<Icon icon='search' class='mr-2 h-3 w-3' />
|
||||
<kbd class='mr-1 font-sans'>⌘</kbd><kbd class='font-sans'>K</kbd>
|
||||
</kbd>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
|
||||
<li data-guest-required class='hidden'>
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
</li>
|
||||
<li>
|
||||
<AccountDropdown />
|
||||
|
||||
<a
|
||||
data-guest-required
|
||||
class='flex hidden h-8 w-28 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-blue-700 px-4 py-2 text-sm font-medium text-white hover:from-blue-500 hover:to-blue-600'
|
||||
href='/signup'
|
||||
>
|
||||
<span>Sign Up</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Mobile Navigation Button -->
|
||||
<button
|
||||
class='block cursor-pointer text-gray-400 hover:text-gray-50 sm:hidden'
|
||||
aria-label='Menu'
|
||||
data-show-mobile-nav
|
||||
>
|
||||
<Icon icon='hamburger' />
|
||||
</button>
|
||||
|
||||
<!-- Mobile Navigation Items -->
|
||||
<div
|
||||
class='fixed bottom-0 left-0 right-0 top-0 z-40 flex hidden items-center bg-slate-900'
|
||||
data-mobile-nav
|
||||
>
|
||||
<button
|
||||
data-close-mobile-nav
|
||||
class='absolute right-6 top-6 block cursor-pointer text-gray-400 hover:text-gray-50'
|
||||
aria-label='Close Menu'
|
||||
>
|
||||
<Icon icon='close' />
|
||||
</button>
|
||||
<ul class='flex w-full flex-col items-center gap-2 md:gap-3'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Roadmaps
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='/best-practices'
|
||||
class='text-xl hover:text-blue-300 md:text-lg'
|
||||
>
|
||||
Best Practices
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Guides
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Videos
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Links for logged in users -->
|
||||
<li data-auth-required class='hidden'>
|
||||
<a href='/account' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
<button
|
||||
data-logout-button
|
||||
class='text-xl text-red-300 hover:text-red-400 md:text-lg'
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-guest-required
|
||||
href='/login'
|
||||
class='hidden text-xl text-white md:text-lg'
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-guest-required
|
||||
href='/signup'
|
||||
class='hidden text-xl text-green-300 hover:text-green-400 md:text-lg'
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<script src='./navigation.ts'></script>
|
||||
44
src/components/Navigation/navigation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from "../../lib/jwt";
|
||||
|
||||
export function logout() {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
// Reloading will automatically redirect the user if required
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const dataset = {
|
||||
...target.dataset,
|
||||
...target.closest('button')?.dataset,
|
||||
};
|
||||
|
||||
// If the user clicks on the logout button, remove the token cookie
|
||||
if (dataset.logoutButton !== undefined) {
|
||||
logout();
|
||||
} else if (dataset.showMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
|
||||
} else if (dataset.closeMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-account-button]')
|
||||
?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document
|
||||
.querySelector('[data-account-dropdown]')
|
||||
?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-command-menu]')
|
||||
?.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('command.k'));
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { getFormattedStars } from '../lib/github';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
|
||||
const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
|
||||
---
|
||||
@@ -30,12 +30,12 @@ const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='https://discord.gg/cJpEt5Qbwa'
|
||||
href="https://discord.gg/cJpEt5Qbwa"
|
||||
target='_blank'
|
||||
class='relative pointer inline-flex items-center border border-black py-1.5 px-3 rounded-lg text-sm hover:text-white hover:bg-black bg-white group'
|
||||
>
|
||||
<Icon icon='discord' class='h-[14px] mr-2 -ml-1 fill-current' />
|
||||
Join on Discord <span class="rounded-sm ml-0.5 px-1.5 py-0.5 text-xs uppercase">/ New</span>
|
||||
Join on Discord
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
46
src/components/PageProgress.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import SpinnerIcon from '../icons/spinner.svg';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export interface Props {
|
||||
initialMessage: string;
|
||||
}
|
||||
|
||||
export function PageProgress(props: Props) {
|
||||
const { initialMessage } = props;
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
|
||||
const $pageProgressMessage = useStore(pageProgressMessage);
|
||||
|
||||
useEffect(() => {
|
||||
if ($pageProgressMessage === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage($pageProgressMessage);
|
||||
}, [$pageProgressMessage]);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Tailwind based spinner for full page */}
|
||||
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
|
||||
<div class="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
|
||||
<img
|
||||
src={SpinnerIcon}
|
||||
alt="Loading"
|
||||
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<h1 className="ml-2">
|
||||
{message}
|
||||
<span className="animate-pulse">...</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/PageSponsor.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import CloseIcon from '../icons/close.svg';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { sponsorHidden } from '../stores/page';
|
||||
|
||||
export type PageSponsorType = {
|
||||
company: string;
|
||||
description: string;
|
||||
gaLabel: string;
|
||||
imageUrl: string;
|
||||
pageUrl: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type V1GetSponsorResponse = {
|
||||
href?: string;
|
||||
sponsor?: PageSponsorType;
|
||||
};
|
||||
|
||||
type PageSponsorProps = {
|
||||
gaPageIdentifier?: string;
|
||||
};
|
||||
|
||||
export function PageSponsor(props: PageSponsorProps) {
|
||||
const { gaPageIdentifier } = props;
|
||||
const $isSponsorHidden = useStore(sponsorHidden);
|
||||
const [sponsor, setSponsor] = useState<PageSponsorType>();
|
||||
|
||||
const loadSponsor = async () => {
|
||||
const currentPath = window.location.pathname;
|
||||
if (
|
||||
currentPath === '/' ||
|
||||
currentPath === '/best-practices' ||
|
||||
currentPath === '/roadmaps' ||
|
||||
currentPath.startsWith('/guides') ||
|
||||
currentPath.startsWith('/videos') ||
|
||||
currentPath.startsWith('/account')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<V1GetSponsorResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
|
||||
{
|
||||
href: window.location.pathname,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response?.sponsor) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSponsor(response.sponsor);
|
||||
|
||||
window.fireEvent({
|
||||
category: 'SponsorImpression',
|
||||
action: `${response.sponsor?.company} Impression`,
|
||||
label:
|
||||
response.sponsor.gaLabel ||
|
||||
`${gaPageIdentifier} / ${response.sponsor?.company} Link`,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.setTimeout(loadSponsor);
|
||||
}, []);
|
||||
|
||||
if ($isSponsorHidden || !sponsor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { url, title, imageUrl, description, company, gaLabel, pageUrl } =
|
||||
sponsor;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener sponsored nofollow"
|
||||
class="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
|
||||
onClick={() => {
|
||||
window.fireEvent({
|
||||
category: 'SponsorClick',
|
||||
action: `${company} Redirect`,
|
||||
label: gaLabel || `${gaPageIdentifier} / ${company} Link`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
|
||||
aria-label="Close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
sponsorHidden.set(true);
|
||||
}}
|
||||
>
|
||||
<img alt="Close" class="h-4 w-4" src={CloseIcon} />
|
||||
</span>
|
||||
<img
|
||||
src={imageUrl}
|
||||
class="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
|
||||
alt="Sponsor Banner"
|
||||
/>
|
||||
<span class="flex flex-1 flex-col justify-between text-sm">
|
||||
<span class="p-[10px]">
|
||||
<span class="mb-0.5 block font-semibold">{title}</span>
|
||||
<span class="block text-gray-500">{description}</span>
|
||||
</span>
|
||||
<span class="sponsor-footer">Partner Content</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
id: string;
|
||||
|
||||
57
src/components/ResourceProgressStats.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
export interface Props {
|
||||
isSecondaryBanner?: boolean;
|
||||
}
|
||||
|
||||
const { isSecondaryBanner = false } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
data-progress-nums-container
|
||||
class:list={[
|
||||
'hidden sm:flex justify-between px-2 bg-white items-center py-1.5 relative striped-loader bg-white',
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': isSecondaryBanner,
|
||||
'rounded-md': !isSecondaryBanner,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p
|
||||
class='flex text-sm opacity-0 transition-opacity duration-300'
|
||||
data-progress-nums
|
||||
>
|
||||
<span
|
||||
class='mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900'
|
||||
>
|
||||
<span data-progress-percentage>0</span>% Done
|
||||
</span>
|
||||
|
||||
<span><span data-progress-done>0</span> completed</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-learning>0</span> in progress</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-skipped>0</span> skipped</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-total>0</span> Total</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-progress-nums-container
|
||||
class='relative block rounded-md border bg-white px-2 py-1.5 text-sm text-sm text-gray-700 sm:hidden striped-loader bg-white -mb-2'
|
||||
>
|
||||
<span data-progress-nums class='opacity-0 transition-opacity duration-300'>
|
||||
<span
|
||||
class='mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900'
|
||||
>
|
||||
<span data-progress-percentage>0</span>% Done
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<span data-progress-done>0</span> of <span data-progress-total>0</span> Done
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||