Compare commits
129 Commits
fix/guide-
...
feat/cmd-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91ad9dd5a3 | ||
|
|
bb23f57204 | ||
|
|
80dac2f7f7 | ||
|
|
c4f3c3529d | ||
|
|
f57b61a920 | ||
|
|
af05d795e3 | ||
|
|
44e5138b32 | ||
|
|
4d7cada25c | ||
|
|
b707aa02d8 | ||
|
|
c9c4516aa7 | ||
|
|
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.
|
||||
5
.gitignore
vendored
@@ -5,7 +5,7 @@ dist/
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
bin/developer-roadmap
|
||||
scripts/developer-roadmap
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
@@ -23,4 +23,5 @@ pnpm-debug.log*
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
tests-examples
|
||||
tests-examples
|
||||
*.csv
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// https://astro.build/config
|
||||
import preact from '@astrojs/preact';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
@@ -6,6 +7,7 @@ import { defineConfig } from 'astro/config';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://roadmap.sh/',
|
||||
markdown: {
|
||||
@@ -56,5 +58,6 @@ export default defineConfig({
|
||||
css: false,
|
||||
js: false,
|
||||
}),
|
||||
preact(),
|
||||
],
|
||||
});
|
||||
|
||||
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.0",
|
||||
"@astrojs/sitemap": "^1.3.1",
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.4.1",
|
||||
"astro": "^2.5.0",
|
||||
"astro-compress": "^1.1.43",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.8.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.14.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.33.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.9.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
6728
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 |
|
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 |
1
public/jsons/best-practices/code-review.json
Normal file
1
public/jsons/roadmaps/docker.json
Normal file
4019
public/jsons/roadmaps/full-stack.json
Normal file
1
public/jsons/roadmaps/postgresql-dba.json
Normal file
4119
public/jsons/roadmaps/prompt-engineering.json
Normal file
BIN
public/pdfs/best-practices/code-review.pdf
Normal file
BIN
public/pdfs/roadmaps/android.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/docker.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
BIN
public/roadmaps/full-stack.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
15
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>
|
||||
@@ -33,6 +33,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
|
||||
@@ -59,9 +60,11 @@ 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)
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ function writeTopicContent(currTopicUrl) {
|
||||
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.`;
|
||||
}
|
||||
|
||||
console.log(`Genearting '${childTopic || parentTopic}'...`);
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
openai
|
||||
@@ -90,6 +90,52 @@ 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);
|
||||
|
||||
@@ -106,50 +152,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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
100
src/components/AuthenticationFlow/EmailLoginForm.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
116
src/components/AuthenticationFlow/GitHubButton.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
116
src/components/AuthenticationFlow/GoogleButton.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
src/components/AuthenticationFlow/LoginPopup.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import EmailLoginForm from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
</h2>
|
||||
<p class='mt-2 text-sm leading-4 text-slate-600'>
|
||||
You must be logged in to perform this action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<EmailLoginForm client:load />
|
||||
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Don't have an account?{' '}
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'>Sign up</a>
|
||||
</div>
|
||||
</Popup>
|
||||
97
src/components/AuthenticationFlow/ResetPasswordForm.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
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);
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
src/components/AuthenticationFlow/TriggerVerifyAccount.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
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>
|
||||
79
src/components/Authenticator/authenticator.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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 = [
|
||||
'/settings/update-profile',
|
||||
'/settings/update-password',
|
||||
];
|
||||
|
||||
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' />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
201
src/components/CommandMenu/CommandMenu.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import GuideIcon from '../../icons/guide.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
|
||||
type PageType = {
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
url: '/settings/update-profile',
|
||||
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, () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleToggleTopic(e: any) {
|
||||
setIsActive(true);
|
||||
}
|
||||
|
||||
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-0"
|
||||
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') {
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,7 +17,9 @@ const { resourceId, resourceType, jsonUrl, 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}
|
||||
@@ -27,4 +29,4 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
|
||||
</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 {
|
||||
renderResourceProgress,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
jsonUrl: string;
|
||||
loaderHTML: string | null;
|
||||
|
||||
containerId: string;
|
||||
loaderId: string;
|
||||
|
||||
constructor() {
|
||||
this.resourceId = '';
|
||||
this.resourceType = '';
|
||||
@@ -32,12 +46,12 @@ 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!;
|
||||
this.jsonUrl = dataset.jsonUrl!;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -46,13 +60,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 +82,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 +102,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,6 +127,8 @@ 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`);
|
||||
} else {
|
||||
@@ -94,16 +136,16 @@ export class Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
switchRoadmap(newJsonUrl) {
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop().replace('.json', '');
|
||||
switchRoadmap(newJsonUrl: string) {
|
||||
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);
|
||||
url.searchParams.set(type, newJsonFileSlug!);
|
||||
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
@@ -119,13 +161,13 @@ export class Renderer {
|
||||
label: `${newJsonFileSlug}`,
|
||||
});
|
||||
|
||||
this.jsonToSvg(newJsonUrl).then(() => {
|
||||
this.containerEl.setAttribute('style', '');
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {
|
||||
this.containerEl?.setAttribute('style', '');
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -167,6 +209,7 @@ export class Renderer {
|
||||
detail: {
|
||||
topicId: normalizedGroupId,
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -175,6 +218,7 @@ export class Renderer {
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
window.addEventListener('click', this.handleSvgClick);
|
||||
// window.addEventListener('contextmenu', this.handleSvgClick);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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='/settings/update-profile'
|
||||
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
>
|
||||
Settings
|
||||
</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>
|
||||
138
src/components/Navigation/Navigation.astro
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import AccountDropdown from './AccountDropdown.astro';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a class='flex items-center text-lg font-medium text-white' href='/' aria-label="roadmap.sh">
|
||||
<Icon icon='logo' />
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
<ul class='hidden space-x-5 sm:flex sm:items-center'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/best-practices' class='text-gray-400 hover:text-white'
|
||||
>Best Practices</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='hidden lg:inline text-gray-400 hover:text-white'>Guides</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='hidden lg:inline text-gray-400 hover:text-white'>Videos</a>
|
||||
</li>
|
||||
<li>
|
||||
<button data-command-menu class="hidden lg:flex items-center gap-2 text-gray-400 border border-gray-800 rounded-md px-2.5 py-1 text-sm hover:bg-gray-800 hover:cursor-pointer">
|
||||
<!-- <Icon icon='search' class='h-3 w-3' /> -->
|
||||
⌘ K
|
||||
</span>
|
||||
</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='/settings/update-profile'
|
||||
class='text-xl hover:text-blue-300 md:text-lg'
|
||||
>
|
||||
Settings
|
||||
</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='/signup'
|
||||
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>
|
||||
45
src/components/Navigation/navigation.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { handleAuthRequired } from '../Authenticator/authenticator';
|
||||
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>
|
||||
|
||||
40
src/components/PageProgress.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useIsFirstRender } from '../hooks/use-is-first-render';
|
||||
import SpinnerIcon from '../icons/spinner.svg';
|
||||
import { pageLoadingMessage } from '../stores/page';
|
||||
|
||||
export interface Props {
|
||||
initialMessage: string;
|
||||
}
|
||||
|
||||
export function PageProgress(props: Props) {
|
||||
const { initialMessage } = props;
|
||||
|
||||
const isFirstRender = useIsFirstRender();
|
||||
const $pageLoadingMessage = useStore(pageLoadingMessage);
|
||||
|
||||
if (!$pageLoadingMessage) {
|
||||
if (!initialMessage || !isFirstRender) {
|
||||
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">
|
||||
{$pageLoadingMessage || initialMessage}
|
||||
<span className="animate-pulse">...</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/PageSponsor.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import CloseIcon from '../icons/close.svg';
|
||||
import { httpGet } from '../lib/http';
|
||||
import { sponsorHidden } from '../stores/page';
|
||||
|
||||
export type PageSponsorType = {
|
||||
company: string;
|
||||
description: string;
|
||||
gaLabel: string;
|
||||
imageUrl: string;
|
||||
pageUrl: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type V1GetSponsorResponse = {
|
||||
href?: string;
|
||||
sponsor?: PageSponsorType;
|
||||
};
|
||||
|
||||
type PageSponsorProps = {
|
||||
gaPageIdentifier?: string;
|
||||
};
|
||||
|
||||
export function PageSponsor(props: PageSponsorProps) {
|
||||
const { gaPageIdentifier } = props;
|
||||
const $isSponsorHidden = useStore(sponsorHidden);
|
||||
const [sponsor, setSponsor] = useState<PageSponsorType>();
|
||||
|
||||
const loadSponsor = async () => {
|
||||
const { response, error } = await httpGet<V1GetSponsorResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
|
||||
{
|
||||
href: window.location.pathname,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response?.sponsor) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSponsor(response.sponsor);
|
||||
|
||||
window.fireEvent({
|
||||
category: 'SponsorImpression',
|
||||
action: `${response.sponsor?.company} Impression`,
|
||||
label:
|
||||
response.sponsor.gaLabel ||
|
||||
`${gaPageIdentifier} / ${response.sponsor?.company} Link`,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.setTimeout(loadSponsor);
|
||||
}, []);
|
||||
|
||||
if ($isSponsorHidden || !sponsor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { url, title, imageUrl, description, company, gaLabel, pageUrl } =
|
||||
sponsor;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener sponsored nofollow"
|
||||
class="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
|
||||
onClick={() => {
|
||||
window.fireEvent({
|
||||
category: 'SponsorClick',
|
||||
action: `${company} Redirect`,
|
||||
label: gaLabel || `${gaPageIdentifier} / ${company} Link`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
|
||||
aria-label="Close"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
sponsorHidden.set(true);
|
||||
}}
|
||||
>
|
||||
<img alt="Close" class="h-4 w-4" src={CloseIcon} />
|
||||
</span>
|
||||
<img
|
||||
src={imageUrl}
|
||||
class="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
|
||||
alt="Sponsor Banner"
|
||||
/>
|
||||
<span class="flex flex-1 flex-col justify-between text-sm">
|
||||
<span class="p-[10px]">
|
||||
<span class="mb-0.5 block font-semibold">{title}</span>
|
||||
<span class="block text-gray-500">{description}</span>
|
||||
</span>
|
||||
<span class="sponsor-footer">Partner Content</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
id: string;
|
||||
|
||||
207
src/components/Profile/UploadProfilePicture.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpCall, httpPost } from '../../lib/http';
|
||||
|
||||
interface PreviewFile extends File {
|
||||
preview: string;
|
||||
}
|
||||
|
||||
type UploadProfilePictureProps = {
|
||||
avatarUrl: string;
|
||||
};
|
||||
|
||||
function getDimensions(file: File) {
|
||||
return new Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>((resolve) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
resolve({ width: 0, height: 0 });
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function validateImage(file: File): Promise<string | null> {
|
||||
const dimensions = await getDimensions(file);
|
||||
|
||||
if (dimensions.width > 3000 || dimensions.height > 3000) {
|
||||
return 'Image dimensions are too big. Maximum 3000x3000 pixels.';
|
||||
}
|
||||
|
||||
if (dimensions.width < 100 || dimensions.height < 100) {
|
||||
return 'Image dimensions are too small. Minimum 100x100 pixels.';
|
||||
}
|
||||
|
||||
if (file.size > 1024 * 1024) {
|
||||
return 'Image size is too big. Maximum 1MB.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
const { avatarUrl } = props;
|
||||
|
||||
const [file, setFile] = useState<PreviewFile | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onImageChange = async (e: Event) => {
|
||||
setError('');
|
||||
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = await validateImage(file);
|
||||
if (error) {
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', 'avatar');
|
||||
formData.append('avatar', file);
|
||||
|
||||
// FIXME: Use `httpCall` helper instead of fetch
|
||||
const res = await fetch(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-upload-profile-picture`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
setError(data?.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
|
||||
// Logout user if token is invalid
|
||||
if (data.status === 401) {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Necessary to revoke the preview URL when the component unmounts for avoiding memory leaks
|
||||
return () => {
|
||||
if (file) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
encType="multipart/form-data"
|
||||
className="mt-8 flex flex-col gap-2"
|
||||
>
|
||||
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
|
||||
Profile Picture
|
||||
</label>
|
||||
<div className="mb-2 mt-2 flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="avatar"
|
||||
title="Change profile picture"
|
||||
className="relative cursor-pointer"
|
||||
>
|
||||
<div className="relative block h-24 w-24 items-center overflow-hidden rounded-full">
|
||||
<img
|
||||
className="absolute inset-0 h-full w-full bg-gray-100 object-cover text-sm leading-8 text-red-700"
|
||||
src={file?.preview || avatarUrl}
|
||||
alt={file?.name ?? 'Error!'}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={() => file && URL.revokeObjectURL(file.preview)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!file && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute bottom-1 right-0 rounded bg-gray-600 px-2 py-1 text-xs leading-none text-gray-50 ring-2 ring-white"
|
||||
onClick={() => {
|
||||
if (isLoading) return;
|
||||
inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="avatar"
|
||||
type="file"
|
||||
name="avatar"
|
||||
accept="image/png, image/jpeg, image/jpg, image/pjpeg"
|
||||
className="hidden"
|
||||
onChange={onImageChange}
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<div className="ml-5 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFile(null);
|
||||
inputRef.current?.value && (inputRef.current.value = '');
|
||||
}}
|
||||
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-red-300 bg-red-100 text-sm font-medium text-red-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex h-9 min-w-[96px] items-center justify-center rounded-md border border-gray-300 text-sm font-medium text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Uploading..' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
---
|
||||
import DownloadPopup from './DownloadPopup.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import RoadmapHint from './RoadmapHint.astro';
|
||||
import RoadmapNote from './RoadmapNote.astro';
|
||||
import SubscribePopup from './SubscribePopup.astro';
|
||||
import TopicSearch from './TopicSearch/TopicSearch.astro';
|
||||
import YouTubeAlert from './YouTubeAlert.astro';
|
||||
|
||||
@@ -18,23 +17,31 @@ export interface Props {
|
||||
hasTopics?: boolean;
|
||||
}
|
||||
|
||||
const { title, description, roadmapId, tnsBannerLink, isUpcoming = false, hasSearch = false, note, hasTopics = false } = Astro.props;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
roadmapId,
|
||||
tnsBannerLink,
|
||||
isUpcoming = false,
|
||||
hasSearch = false,
|
||||
note,
|
||||
hasTopics = false,
|
||||
} = Astro.props;
|
||||
|
||||
const isRoadmapReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<DownloadPopup />
|
||||
<SubscribePopup />
|
||||
<LoginPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='py-5 sm:py-12 container relative'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<YouTubeAlert />
|
||||
|
||||
<div class='mt-0 mb-3 sm:mb-4 sm:mt-4'>
|
||||
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
|
||||
<div class='mb-3 mt-0 sm:mb-4 sm:mt-4'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
{title}
|
||||
</h1>
|
||||
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
|
||||
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between'>
|
||||
@@ -44,33 +51,42 @@ const isRoadmapReady = !isUpcoming;
|
||||
<>
|
||||
<a
|
||||
href='/roadmaps'
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to All Roadmaps'
|
||||
>
|
||||
←<span class='hidden sm:inline'> All Roadmaps</span>
|
||||
</a>
|
||||
|
||||
{isRoadmapReady && (
|
||||
<button
|
||||
data-popup='download-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
aria-label='Download Roadmap'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Download Roadmap Popup'
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='hidden sm:inline ml-2'>Download</span>
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
data-auth-required
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
target='_blank'
|
||||
href={`/pdfs/roadmaps/${roadmapId}.pdf`}
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-popup='subscribe-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Subscribe for Updates'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Subscribe Roadmap Popup'
|
||||
>
|
||||
<Icon icon='email' />
|
||||
<span class='ml-2'>Subscribe</span>
|
||||
@@ -83,7 +99,7 @@ const isRoadmapReady = !isUpcoming;
|
||||
hasSearch && (
|
||||
<a
|
||||
href={`/${roadmapId}`}
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to Visual Roadmap'
|
||||
>
|
||||
←
|
||||
@@ -98,7 +114,7 @@ const isRoadmapReady = !isUpcoming;
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
|
||||
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Suggest Changes'
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
@@ -110,7 +126,11 @@ const isRoadmapReady = !isUpcoming;
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
{hasTopics && <RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />}
|
||||
{
|
||||
hasTopics && (
|
||||
<RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />
|
||||
)
|
||||
}
|
||||
|
||||
{hasSearch && <TopicSearch />}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
roadmapId: string;
|
||||
@@ -9,7 +9,10 @@ export interface Props {
|
||||
const { roadmapId, tnsBannerLink = '' } = Astro.props;
|
||||
|
||||
const hasTNSBanner = !!tnsBannerLink;
|
||||
const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
|
||||
const roadmapTitle =
|
||||
roadmapId === 'devops'
|
||||
? 'DevOps'
|
||||
: `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -23,16 +26,13 @@ const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).
|
||||
>
|
||||
{
|
||||
hasTNSBanner && (
|
||||
<div class='px-2 py-1.5 border-b bg-gray-100 hidden sm:block'>
|
||||
<div class='hidden border-b bg-gray-100 px-2 py-1.5 sm:block'>
|
||||
<p class='text-sm'>
|
||||
Get the latest {roadmapTitle} news from our sister site{' '}
|
||||
<a
|
||||
href={tnsBannerLink}
|
||||
target='_blank'
|
||||
class='font-semibold underline'
|
||||
ga-category='PartnerClick'
|
||||
ga-action='TNS Referral'
|
||||
ga-label='TNS Referral - Roadmap'
|
||||
>
|
||||
TheNewStack.io
|
||||
</a>
|
||||
@@ -52,13 +52,16 @@ const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).
|
||||
]}
|
||||
>
|
||||
<p class='text-sm'>
|
||||
<span class='text-yellow-900 bg-yellow-200 py-0.5 px-1 text-xs rounded-sm font-medium uppercase mr-0.5'>New</span>
|
||||
<span
|
||||
class='mr-0.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900'
|
||||
>New</span
|
||||
>
|
||||
Resources are here, try clicking nodes
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={`/${roadmapId}/topics`}
|
||||
class='inline-flex items-center justify-center py-1.5 text-sm font-medium rounded-md hover:text-black text-gray-500 px-1'
|
||||
class='inline-flex items-center justify-center rounded-md px-1 py-1.5 text-sm font-medium text-gray-500 hover:text-black'
|
||||
>
|
||||
<Icon icon='search' />
|
||||
<span class='ml-2'>Search Topics</span>
|
||||
@@ -66,8 +69,12 @@ const roadmapTitle = roadmapId === 'devops' ? 'DevOps' : `${roadmapId.charAt(0).
|
||||
</div>
|
||||
|
||||
<!-- Mobile - Roadmap resources alert -->
|
||||
<p class='block sm:hidden text-sm border border-yellow-500 text-yellow-700 rounded-md py-1.5 px-2 bg-white relative'>
|
||||
<p
|
||||
class='relative block rounded-md border border-yellow-500 bg-white px-2 py-1.5 text-sm text-yellow-700 sm:hidden'
|
||||
>
|
||||
Click roadmap items for resources or visit{' '}
|
||||
<a href={`/${roadmapId}/topics`} class='text-blue-700 underline'> resources list</a>.
|
||||
<a href={`/${roadmapId}/topics`} class='text-blue-700 underline'>
|
||||
resources list</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
81
src/components/Setting/SettingSidebar.astro
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
const { pageUrl, name } = Astro.props;
|
||||
|
||||
export interface Props {
|
||||
pageUrl: string;
|
||||
name: string;
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
class='container flex min-h-[calc(100vh-37px-70px)] items-stretch sm:min-h-[calc(100vh-37px-96px)]'
|
||||
>
|
||||
<aside class='hidden w-56 border-r border-slate-200 py-10 pr-5 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}`
|
||||
>Profile</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-password'
|
||||
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}`
|
||||
>Security</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class='grow py-10 pl-0 md:p-10 md:pr-0'>
|
||||
<div class='relative mb-5 md:hidden'>
|
||||
<button
|
||||
class='flex h-10 w-full items-center justify-between rounded-md bg-slate-800 px-2 text-center font-medium text-slate-100'
|
||||
id='settings-menu'
|
||||
>
|
||||
{name}
|
||||
<Icon icon='dropdown' />
|
||||
</button>
|
||||
<ul
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute mt-1 hidden w-full space-y-1.5 rounded-md bg-white p-2 shadow-lg'
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}`
|
||||
>Profile</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-password'
|
||||
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}`
|
||||
>Change password</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
172
src/components/Setting/UpdatePasswordForm.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
|
||||
export default function UpdatePasswordForm() {
|
||||
const [authProvider, setAuthProvider] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (newPassword !== newPasswordConfirmation) {
|
||||
setError('Passwords do not match');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-password`,
|
||||
{
|
||||
oldPassword: authProvider === 'email' ? currentPassword : 'social-auth',
|
||||
password: newPassword,
|
||||
confirmPassword: newPasswordConfirmation,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setNewPasswordConfirmation('');
|
||||
setSuccess('Password updated successfully');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, response } = await httpGet(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-me`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { authProvider } = response;
|
||||
setAuthProvider(authProvider);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile().finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Password</h2>
|
||||
<p className="mt-2">Use the form below to update your password.</p>
|
||||
<div className="mt-8 space-y-4">
|
||||
{authProvider === 'email' && (
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="current-password"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
disabled={authProvider !== 'email'}
|
||||
type="password"
|
||||
name="current-password"
|
||||
id="current-password"
|
||||
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 disabled:bg-gray-100"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Current password"
|
||||
value={currentPassword}
|
||||
onInput={(e) =>
|
||||
setCurrentPassword((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="new-password"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-password"
|
||||
id="new-password"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onInput={(e) =>
|
||||
setNewPassword((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="new-password-confirmation"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
New Password Confirm
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-password-confirmation"
|
||||
id="new-password-confirmation"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New password confirm"
|
||||
value={newPasswordConfirmation}
|
||||
onInput={(e) =>
|
||||
setNewPasswordConfirmation((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p class="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p class="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
|
||||
{success}
|
||||
</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...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
210
src/components/Setting/UpdateProfileForm.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
import UploadProfilePicture from '../Profile/UploadProfilePicture';
|
||||
|
||||
export function UpdateProfileForm() {
|
||||
const [name, setName] = useState('');
|
||||
const [avatar, setAvatar] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [github, setGithub] = useState('');
|
||||
const [twitter, setTwitter] = useState('');
|
||||
const [linkedin, setLinkedin] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`,
|
||||
{
|
||||
name,
|
||||
github: github || undefined,
|
||||
linkedin: linkedin || undefined,
|
||||
twitter: twitter || undefined,
|
||||
website: website || undefined,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await loadProfile();
|
||||
setSuccess('Profile updated successfully');
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
// Set the loading state
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, response } = await httpGet(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-me`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, email, links, avatar } = response;
|
||||
|
||||
setName(name);
|
||||
setEmail(email);
|
||||
setGithub(links?.github || '');
|
||||
setLinkedin(links?.linkedin || '');
|
||||
setTwitter(links?.twitter || '');
|
||||
setWebsite(links?.website || '');
|
||||
setAvatar(avatar || '');
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Make a request to the backend to fill in the form with the current values
|
||||
useEffect(() => {
|
||||
loadProfile().finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
|
||||
<p className="mt-2">Update your profile details below.</p>
|
||||
<UploadProfilePicture
|
||||
avatarUrl={
|
||||
avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
|
||||
: '/images/default-avatar.png'
|
||||
}
|
||||
/>
|
||||
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="email"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
disabled
|
||||
placeholder="john@example.com"
|
||||
value={email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="github" className="text-sm leading-none text-slate-500">
|
||||
Github
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="github"
|
||||
id="github"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/username"
|
||||
value={github}
|
||||
onInput={(e) => setGithub((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="twitter" className="text-sm leading-none text-slate-500">
|
||||
Twitter
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="twitter"
|
||||
id="twitter"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://twitter.com/username"
|
||||
value={twitter}
|
||||
onInput={(e) => setTwitter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="linkedin" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="linkedin"
|
||||
id="linkedin"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://www.linkedin.com/in/username/"
|
||||
value={linkedin}
|
||||
onInput={(e) => setLinkedin((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://example.com"
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p className="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
|
||||
{success}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
pageUrl: string;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
import type { GAEventType } from '../Analytics/analytics';
|
||||
import Icon from '../Icon.astro';
|
||||
|
||||
export type SponsorType = {
|
||||
url: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
description: string;
|
||||
event: GAEventType;
|
||||
};
|
||||
|
||||
export interface Props {
|
||||
sponsor: SponsorType;
|
||||
}
|
||||
|
||||
const {
|
||||
sponsor: { title, url, description, imageUrl, event },
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<script src='./sponsor.js'></script>
|
||||
|
||||
<a
|
||||
href={url}
|
||||
id='sponsor-ad'
|
||||
target='_blank'
|
||||
rel='noopener sponsored nofollow'
|
||||
ga-category={event?.category}
|
||||
ga-action={event?.action}
|
||||
ga-label={event?.label}
|
||||
class='fixed bottom-[15px] right-[15px] outline-transparent z-50 bg-white max-w-[350px] shadow-lg outline-0 hidden'
|
||||
>
|
||||
<button
|
||||
class='absolute top-1.5 right-1.5 text-gray-300 hover:text-gray-800'
|
||||
aria-label='Close'
|
||||
close-sponsor
|
||||
>
|
||||
<Icon icon='close' class='h-4' />
|
||||
</button>
|
||||
<img src={imageUrl} class='h-[150px] lg:h-[169px]' alt='Sponsor Banner' />
|
||||
<span class='text-sm flex flex-col justify-between'>
|
||||
<span class='p-[10px]'>
|
||||
<span class='font-semibold mb-0.5 block'>{title}</span>
|
||||
<span class='text-gray-500 block'>{description}</span>
|
||||
</span>
|
||||
<span class='sponsor-footer'>Partner Content</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<script>
|
||||
document.querySelector('[close-sponsor]')?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.getElementById('sponsor-ad')?.classList.add('hidden');
|
||||
});
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
window.setTimeout(() => {
|
||||
const ad = document.querySelector('#sponsor-ad');
|
||||
if (!ad) {
|
||||
return;
|
||||
}
|
||||
|
||||
ad.classList.remove('hidden');
|
||||
ad.classList.add('flex');
|
||||
}, 500);
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
import Popup from './Popup/Popup.astro';
|
||||
import CaptchaFields from './Captcha/CaptchaFields.astro';
|
||||
---
|
||||
|
||||
<Popup id='subscribe-popup' title='Subscribe' subtitle='Enter your email below to receive updates.'>
|
||||
<form
|
||||
action='https://news.roadmap.sh/subscribe'
|
||||
method='POST'
|
||||
accept-charset='utf-8'
|
||||
target='_blank'
|
||||
captcha-form
|
||||
>
|
||||
<input type='hidden' name='gdpr' value='true' />
|
||||
|
||||
<input
|
||||
type='email'
|
||||
name='email'
|
||||
required
|
||||
autofocus
|
||||
class='w-full rounded-md border text-md py-2.5 px-3 mb-2'
|
||||
placeholder='Enter your Email'
|
||||
/>
|
||||
|
||||
<CaptchaFields />
|
||||
|
||||
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
|
||||
<input type='hidden' name='subform' value='yes' />
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
name='submit'
|
||||
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2'
|
||||
ga-category='Subscription'
|
||||
ga-action='Submitted Popup Form'
|
||||
ga-label='Subscribe Roadmap Popup'
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</Popup>
|
||||
216
src/components/TopicDetail/TopicDetail.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useLoadTopic } from '../../hooks/use-load-topic';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useToggleTopic } from '../../hooks/use-toggle-topic';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
isTopicDone,
|
||||
renderTopicProgress,
|
||||
ResourceType,
|
||||
updateResourceProgress as updateResourceProgressApi,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageLoadingMessage, sponsorHidden } from '../../stores/page';
|
||||
import { TopicProgressButton } from './TopicProgressButton';
|
||||
|
||||
export function TopicDetail() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
const topicRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Details of the currently loaded topic
|
||||
const [topicId, setTopicId] = useState('');
|
||||
const [resourceId, setResourceId] = useState('');
|
||||
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
|
||||
|
||||
const showLoginPopup = () => {
|
||||
const popupEl = document.querySelector(`#login-popup`);
|
||||
if (!popupEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupEl.classList.remove('hidden');
|
||||
popupEl.classList.add('flex');
|
||||
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]');
|
||||
if (focusEl) {
|
||||
focusEl.focus();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Close the topic detail when user clicks outside the topic detail
|
||||
useOutsideClick(topicRef, () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
// Toggle topic is available even if the component UI is not active
|
||||
// This is used on the best practice screen where we have the checkboxes
|
||||
// to mark the topic as done/undone.
|
||||
useToggleTopic(({ topicId, resourceType, resourceId }) => {
|
||||
if (isGuest) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
pageLoadingMessage.set('Updating');
|
||||
|
||||
// Toggle the topic status
|
||||
isTopicDone({ topicId, resourceId, resourceType })
|
||||
.then((oldIsDone) =>
|
||||
updateResourceProgressApi(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
oldIsDone ? 'pending' : 'done'
|
||||
)
|
||||
)
|
||||
.then(({ done = [] }) => {
|
||||
renderTopicProgress(
|
||||
topicId,
|
||||
done.includes(topicId) ? 'done' : 'pending'
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
});
|
||||
});
|
||||
|
||||
// Load the topic detail when the topic detail is active
|
||||
useLoadTopic(({ topicId, resourceType, resourceId }) => {
|
||||
setIsLoading(true);
|
||||
setIsActive(true);
|
||||
sponsorHidden.set(true);
|
||||
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
const topicUrl =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}/${topicPartial}`
|
||||
: `/best-practices/${resourceId}/${topicPartial}`;
|
||||
|
||||
httpGet<string>(
|
||||
topicUrl,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(({ response }) => {
|
||||
if (!response) {
|
||||
setError('Topic not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// It's full HTML with page body, head etc.
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(response, 'text/html');
|
||||
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
|
||||
|
||||
setIsLoading(false);
|
||||
setTopicHtml(topicHtml);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contributionDir =
|
||||
resourceType === 'roadmap' ? 'roadmaps' : 'best-practices';
|
||||
const contributionUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/${contributionDir}/${resourceId}/content`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={topicRef}
|
||||
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-center">
|
||||
<img
|
||||
src={SpinnerIcon}
|
||||
alt="Loading"
|
||||
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{/* Actions for the topic */}
|
||||
<div className="mb-2">
|
||||
<TopicProgressButton
|
||||
topicId={topicId}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onShowLoginPopup={showLoginPopup}
|
||||
onClose={() => {
|
||||
setIsActive(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="close-topic"
|
||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||
onClick={() => setIsActive(false)}
|
||||
>
|
||||
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Topic Content */}
|
||||
<div
|
||||
id="topic-content"
|
||||
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5"
|
||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||
></div>
|
||||
|
||||
<p
|
||||
id="contrib-meta"
|
||||
class="mt-10 border-t pt-3 text-sm leading-relaxed text-gray-400"
|
||||
>
|
||||
Contribute links to learning resources about this topic{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
class="text-blue-700 underline"
|
||||
href={contributionUrl}
|
||||
>
|
||||
on GitHub repository.
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
src/components/TopicDetail/TopicProgressButton.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import DownIcon from '../../icons/down.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
getTopicStatus,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
|
||||
type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
|
||||
onShowLoginPopup: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const statusColors: Record<ResourceProgressType, string> = {
|
||||
done: 'bg-green-500',
|
||||
learning: 'bg-yellow-500',
|
||||
pending: 'bg-gray-300',
|
||||
skipped: 'bg-black',
|
||||
};
|
||||
|
||||
export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const { topicId, resourceId, resourceType, onClose, onShowLoginPopup } =
|
||||
props;
|
||||
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
const [progress, setProgress] = useState<ResourceProgressType>('pending');
|
||||
const [showChangeStatus, setShowChangeStatus] = useState(false);
|
||||
|
||||
const changeStatusRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useOutsideClick(changeStatusRef, () => {
|
||||
setShowChangeStatus(false);
|
||||
});
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId || !resourceId || !resourceType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingProgress(true);
|
||||
getTopicStatus({ topicId, resourceId, resourceType })
|
||||
.then((status) => {
|
||||
setIsUpdatingProgress(false);
|
||||
setProgress(status);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [topicId, resourceId, resourceType]);
|
||||
|
||||
// Mark as done
|
||||
useKeydown(
|
||||
'd',
|
||||
() => {
|
||||
if (progress === 'done') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateResourceProgress('done');
|
||||
},
|
||||
[progress]
|
||||
);
|
||||
|
||||
// Mark as learning
|
||||
useKeydown(
|
||||
'l',
|
||||
() => {
|
||||
if (progress === 'learning') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateResourceProgress('learning');
|
||||
},
|
||||
[progress]
|
||||
);
|
||||
|
||||
// Mark as learning
|
||||
useKeydown(
|
||||
's',
|
||||
() => {
|
||||
if (progress === 'skipped') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateResourceProgress('skipped');
|
||||
},
|
||||
[progress]
|
||||
);
|
||||
|
||||
// Mark as pending
|
||||
useKeydown(
|
||||
'r',
|
||||
() => {
|
||||
console.log(progress);
|
||||
if (progress === 'pending') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
handleUpdateResourceProgress('pending');
|
||||
},
|
||||
[progress]
|
||||
);
|
||||
|
||||
const handleUpdateResourceProgress = (progress: ResourceProgressType) => {
|
||||
if (isGuest) {
|
||||
onClose();
|
||||
onShowLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingProgress(true);
|
||||
updateResourceProgress(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
progress
|
||||
)
|
||||
.then(() => {
|
||||
setProgress(progress);
|
||||
onClose();
|
||||
renderTopicProgress(topicId, progress);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingProgress(false);
|
||||
});
|
||||
};
|
||||
|
||||
const allowMarkingSkipped = ['pending', 'learning', 'done'].includes(
|
||||
progress
|
||||
);
|
||||
const allowMarkingDone = ['skipped', 'pending', 'learning'].includes(
|
||||
progress
|
||||
);
|
||||
const allowMarkingLearning = ['done', 'skipped', 'pending'].includes(
|
||||
progress
|
||||
);
|
||||
const allowMarkingPending = ['skipped', 'done', 'learning'].includes(
|
||||
progress
|
||||
);
|
||||
|
||||
if (isUpdatingProgress) {
|
||||
return (
|
||||
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
|
||||
<img alt="Check" class="h-4 w-4 animate-spin" src={SpinnerIcon} />
|
||||
<span className="ml-2">Updating Status..</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex rounded-md border border-gray-300">
|
||||
<span className="inline-flex cursor-default items-center p-1 px-2 text-sm text-black">
|
||||
<span class="flex h-2 w-2">
|
||||
<span
|
||||
class={`relative inline-flex h-2 w-2 rounded-full ${statusColors[progress]}`}
|
||||
></span>
|
||||
</span>
|
||||
<span className="ml-2 capitalize">
|
||||
{progress === 'learning' ? 'In Progress' : progress}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center rounded-br-md rounded-tr-md border-l border-l-gray-300 bg-gray-100 p-1 px-2 text-sm text-black hover:bg-gray-200"
|
||||
onClick={() => setShowChangeStatus(true)}
|
||||
>
|
||||
<span className="mr-0.5">Update Status</span>
|
||||
<img alt="Check" class="h-4 w-4" src={DownIcon} />
|
||||
</button>
|
||||
|
||||
{showChangeStatus && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 flex min-w-[160px] flex-col divide-y rounded-md border border-gray-200 bg-white shadow-md [&>button:first-child]:rounded-t-md [&>button:last-child]:rounded-b-md"
|
||||
ref={changeStatusRef!}
|
||||
>
|
||||
{allowMarkingDone && (
|
||||
<button
|
||||
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
onClick={() => handleUpdateResourceProgress('done')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['done']}`}
|
||||
></span>
|
||||
Done
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">D</span>
|
||||
</button>
|
||||
)}
|
||||
{allowMarkingLearning && (
|
||||
<button
|
||||
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
onClick={() => handleUpdateResourceProgress('learning')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['learning']}`}
|
||||
></span>
|
||||
In Progress
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-gray-500">L</span>
|
||||
</button>
|
||||
)}
|
||||
{allowMarkingPending && (
|
||||
<button
|
||||
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
onClick={() => handleUpdateResourceProgress('pending')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['pending']}`}
|
||||
></span>
|
||||
Reset
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">R</span>
|
||||
</button>
|
||||
)}
|
||||
{allowMarkingSkipped && (
|
||||
<button
|
||||
class="inline-flex justify-between px-3 py-1.5 text-left text-sm text-gray-800 hover:bg-gray-100"
|
||||
onClick={() => handleUpdateResourceProgress('skipped')}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class={`mr-2 inline-block h-2 w-2 rounded-full ${statusColors['skipped']}`}
|
||||
></span>
|
||||
Skip
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">S</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Loader from '../Loader.astro';
|
||||
|
||||
export interface Props {
|
||||
contentContributionLink: string;
|
||||
}
|
||||
|
||||
const { contentContributionLink } = Astro.props;
|
||||
---
|
||||
|
||||
<div id='topic-overlay' class='hidden'>
|
||||
<div
|
||||
class='fixed top-0 right-0 z-40 h-screen p-4 sm:p-6 overflow-y-auto bg-white w-full sm:max-w-[600px]'
|
||||
tabindex='-1'
|
||||
id='topic-body'
|
||||
>
|
||||
<div id='topic-loader' class='hidden'>
|
||||
<Loader />
|
||||
</div>
|
||||
|
||||
<div id='topic-actions' class='hidden mb-2'>
|
||||
<button
|
||||
id='mark-topic-done'
|
||||
ga-category='TopicClick'
|
||||
ga-action='topic/mark-completion'
|
||||
ga-label='done'
|
||||
class='bg-green-600 text-white p-1 px-2 text-sm rounded-md hover:bg-green-700 inline-flex items-center'
|
||||
>
|
||||
<Icon icon='check' />
|
||||
<span class='ml-2'>Mark as Done</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id='mark-topic-pending'
|
||||
ga-category='TopicClick'
|
||||
ga-action='topic/mark-completion'
|
||||
ga-label='pending'
|
||||
class='hidden bg-red-600 text-white p-1 px-2 text-sm rounded-md hover:bg-red-700 inline-flex items-center'
|
||||
>
|
||||
<Icon icon='reset' />
|
||||
<span class='ml-2'>Mark as Pending</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
id='close-topic'
|
||||
class='text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center'
|
||||
>
|
||||
<Icon icon='close' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id='topic-content'
|
||||
class='prose prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-quoteless prose-blockquote:font-normal prose-h1:mt-7 prose-h1:mb-2.5 prose-p:mt-0 prose-p:mb-2 prose-li:m-0 prose-li:mb-0.5 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mt-[10px] prose-h3:mb-[5px]'
|
||||
>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id='contrib-meta'
|
||||
class='text-gray-400 text-sm border-t pt-3 mt-10 hidden'
|
||||
>
|
||||
We are still working on this page. You can contribute by submitting a
|
||||
brief description and a few links to learn more about this topic <a
|
||||
target='_blank'
|
||||
class='underline text-blue-700'
|
||||
href={contentContributionLink}>on GitHub repository.</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<div class='bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-30'>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./topic.js" />
|
||||
@@ -1,324 +0,0 @@
|
||||
export class Topic {
|
||||
constructor() {
|
||||
this.overlayId = 'topic-overlay';
|
||||
this.contentId = 'topic-content';
|
||||
this.loaderId = 'topic-loader';
|
||||
this.topicBodyId = 'topic-body';
|
||||
this.topicActionsId = 'topic-actions';
|
||||
this.markTopicDoneId = 'mark-topic-done';
|
||||
this.markTopicPendingId = 'mark-topic-pending';
|
||||
this.closeTopicId = 'close-topic';
|
||||
this.contributionTextId = 'contrib-meta';
|
||||
|
||||
this.activeResourceType = null;
|
||||
this.activeResourceId = null;
|
||||
this.activeTopicId = null;
|
||||
|
||||
this.handleRoadmapTopicClick = this.handleRoadmapTopicClick.bind(this);
|
||||
this.handleBestPracticeTopicClick =
|
||||
this.handleBestPracticeTopicClick.bind(this);
|
||||
this.handleBestPracticeTopicToggle =
|
||||
this.handleBestPracticeTopicToggle.bind(this);
|
||||
this.handleBestPracticeTopicPending =
|
||||
this.handleBestPracticeTopicPending.bind(this);
|
||||
|
||||
this.close = this.close.bind(this);
|
||||
this.resetDOM = this.resetDOM.bind(this);
|
||||
this.populate = this.populate.bind(this);
|
||||
this.handleOverlayClick = this.handleOverlayClick.bind(this);
|
||||
this.markAsDone = this.markAsDone.bind(this);
|
||||
this.markAsPending = this.markAsPending.bind(this);
|
||||
this.querySvgElementsByTopicId = this.querySvgElementsByTopicId.bind(this);
|
||||
this.rightClickListener = this.rightClickListener.bind(this);
|
||||
this.isTopicDone = this.isTopicDone.bind(this);
|
||||
|
||||
this.init = this.init.bind(this);
|
||||
}
|
||||
|
||||
get loaderEl() {
|
||||
return document.getElementById(this.loaderId);
|
||||
}
|
||||
|
||||
get markTopicDoneEl() {
|
||||
return document.getElementById(this.markTopicDoneId);
|
||||
}
|
||||
|
||||
get markTopicPendingEl() {
|
||||
return document.getElementById(this.markTopicPendingId);
|
||||
}
|
||||
|
||||
get topicActionsEl() {
|
||||
return document.getElementById(this.topicActionsId);
|
||||
}
|
||||
|
||||
get contributionTextEl() {
|
||||
return document.getElementById(this.contributionTextId);
|
||||
}
|
||||
|
||||
get contentEl() {
|
||||
return document.getElementById(this.contentId);
|
||||
}
|
||||
|
||||
get overlayEl() {
|
||||
return document.getElementById(this.overlayId);
|
||||
}
|
||||
|
||||
rightClickListener(e) {
|
||||
const groupId = e.target?.closest('g')?.dataset?.groupId;
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (this.isTopicDone(groupId)) {
|
||||
this.markAsPending(groupId);
|
||||
} else {
|
||||
this.markAsDone(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
resetDOM(hideOverlay = false) {
|
||||
if (hideOverlay) {
|
||||
this.overlayEl.classList.add('hidden');
|
||||
} else {
|
||||
this.overlayEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
this.loaderEl.classList.remove('hidden'); // Show loader
|
||||
this.topicActionsEl.classList.add('hidden'); // Hide Actions
|
||||
this.contributionTextEl.classList.add('hidden'); // Hide contribution text
|
||||
this.contentEl.replaceChildren(''); // Remove content
|
||||
}
|
||||
|
||||
close() {
|
||||
this.resetDOM(true);
|
||||
|
||||
this.activeResourceId = null;
|
||||
this.activeTopicId = null;
|
||||
}
|
||||
|
||||
isTopicDone(topicId) {
|
||||
const normalizedGroup = topicId.replace(/^\d+-/, '');
|
||||
return localStorage.getItem(normalizedGroup) === 'done';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | HTMLElement} html
|
||||
*/
|
||||
populate(html) {
|
||||
this.contentEl.replaceChildren(html);
|
||||
this.loaderEl.classList.add('hidden');
|
||||
this.topicActionsEl.classList.remove('hidden');
|
||||
this.contributionTextEl.classList.remove('hidden');
|
||||
|
||||
const isDone = this.isTopicDone(this.activeTopicId);
|
||||
|
||||
if (isDone) {
|
||||
this.markTopicDoneEl.classList.add('hidden');
|
||||
this.markTopicPendingEl.classList.remove('hidden');
|
||||
} else {
|
||||
this.markTopicDoneEl.classList.remove('hidden');
|
||||
this.markTopicPendingEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
renderTopicFromUrl(url) {
|
||||
return fetch(url)
|
||||
.then((res) => {
|
||||
return res.text();
|
||||
})
|
||||
.then((topicHtml) => {
|
||||
// It's full HTML with page body, head etc.
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(topicHtml, 'text/html');
|
||||
|
||||
return node.getElementById('main-content');
|
||||
})
|
||||
.then((content) => {
|
||||
this.populate(content);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
this.populate('Error loading the content!');
|
||||
});
|
||||
}
|
||||
|
||||
handleBestPracticeTopicToggle(e) {
|
||||
const { resourceId: bestPracticeId, topicId } = e.detail;
|
||||
if (!topicId || !bestPracticeId) {
|
||||
console.log('Missing topic or bestPracticeId: ', e.detail);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDone = localStorage.getItem(topicId) === 'done';
|
||||
if (isDone) {
|
||||
this.markAsPending(topicId);
|
||||
} else {
|
||||
this.markAsDone(topicId);
|
||||
}
|
||||
}
|
||||
|
||||
handleBestPracticeTopicPending(e) {
|
||||
const { resourceId: bestPracticeId, topicId } = e.detail;
|
||||
if (!topicId || !bestPracticeId) {
|
||||
console.log('Missing topic or bestPracticeId: ', e.detail);
|
||||
return;
|
||||
}
|
||||
|
||||
this.markAsPending(topicId);
|
||||
}
|
||||
|
||||
handleBestPracticeTopicClick(e) {
|
||||
const { resourceId: bestPracticeId, topicId } = e.detail;
|
||||
if (!topicId || !bestPracticeId) {
|
||||
console.log('Missing topic or bestPracticeId: ', e.detail);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeResourceType = 'best-practice';
|
||||
this.activeResourceId = bestPracticeId;
|
||||
this.activeTopicId = topicId;
|
||||
|
||||
this.resetDOM();
|
||||
|
||||
const topicUrl = `/best-practices/${bestPracticeId}/${topicId.replaceAll(
|
||||
':',
|
||||
'/'
|
||||
)}`;
|
||||
|
||||
this.renderTopicFromUrl(topicUrl).then(() => null);
|
||||
}
|
||||
|
||||
handleRoadmapTopicClick(e) {
|
||||
const { resourceId: roadmapId, topicId } = e.detail;
|
||||
if (!topicId || !roadmapId) {
|
||||
console.log('Missing topic or roadmap: ', e.detail);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeResourceType = 'roadmap';
|
||||
this.activeResourceId = roadmapId;
|
||||
this.activeTopicId = topicId;
|
||||
|
||||
this.resetDOM();
|
||||
const topicUrl = `/${roadmapId}/${topicId.replaceAll(':', '/')}`;
|
||||
|
||||
window.fireEvent({
|
||||
category: `RoadmapClick`,
|
||||
action: `${roadmapId}/load-topic`,
|
||||
label: topicUrl,
|
||||
});
|
||||
|
||||
this.renderTopicFromUrl(topicUrl).then(() => null);
|
||||
}
|
||||
|
||||
querySvgElementsByTopicId(topicId) {
|
||||
const matchingElements = [];
|
||||
|
||||
// Elements having sort order in the beginning of the group id
|
||||
document
|
||||
.querySelectorAll(`[data-group-id$="-${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
const foundGroupId = element?.dataset?.groupId || '';
|
||||
const validGroupRegex = new RegExp(`^\\d+-${topicId}$`);
|
||||
|
||||
if (validGroupRegex.test(foundGroupId)) {
|
||||
matchingElements.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Elements with exact match of the topic id
|
||||
document
|
||||
.querySelectorAll(`[data-group-id="${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
|
||||
// Matching "check:XXXX" box of the topic
|
||||
document
|
||||
.querySelectorAll(`[data-group-id="check:${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
|
||||
return matchingElements;
|
||||
}
|
||||
|
||||
markAsDone(topicId) {
|
||||
const updatedTopicId = topicId.replace(/^\d+-/, '');
|
||||
localStorage.setItem(updatedTopicId, 'done');
|
||||
|
||||
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
|
||||
item?.classList?.add('done');
|
||||
});
|
||||
}
|
||||
|
||||
markAsPending(topicId) {
|
||||
const updatedTopicId = topicId.replace(/^\d+-/, '');
|
||||
|
||||
localStorage.removeItem(updatedTopicId);
|
||||
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
|
||||
item?.classList?.remove('done');
|
||||
});
|
||||
}
|
||||
|
||||
handleOverlayClick(e) {
|
||||
const isClickedInsideTopic = e.target.closest(`#${this.topicBodyId}`);
|
||||
|
||||
if (!isClickedInsideTopic) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const isClickedDone =
|
||||
e.target.id === this.markTopicDoneId ||
|
||||
e.target.closest(`#${this.markTopicDoneId}`);
|
||||
if (isClickedDone) {
|
||||
this.markAsDone(this.activeTopicId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
const isClickedPending =
|
||||
e.target.id === this.markTopicPendingId ||
|
||||
e.target.closest(`#${this.markTopicPendingId}`);
|
||||
if (isClickedPending) {
|
||||
this.markAsPending(this.activeTopicId);
|
||||
this.close();
|
||||
}
|
||||
|
||||
const isClickedClose =
|
||||
e.target.id === this.closeTopicId ||
|
||||
e.target.closest(`#${this.closeTopicId}`);
|
||||
if (isClickedClose) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
window.addEventListener(
|
||||
'best-practice.topic.click',
|
||||
this.handleBestPracticeTopicClick
|
||||
);
|
||||
window.addEventListener(
|
||||
'best-practice.topic.toggle',
|
||||
this.handleBestPracticeTopicToggle
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
'roadmap.topic.click',
|
||||
this.handleRoadmapTopicClick
|
||||
);
|
||||
window.addEventListener('click', this.handleOverlayClick);
|
||||
window.addEventListener('contextmenu', this.rightClickListener);
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key.toLowerCase() === 'escape') {
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the topic loader
|
||||
const topic = new Topic();
|
||||
topic.init();
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
---
|
||||
|
||||
<script src='./topics.js'></script>
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
---
|
||||
import CaptchaFields from './Captcha/CaptchaFields.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<div class='my-0 px-5 rounded-lg text-left sm:text-center sm:pb-10 pb-8'>
|
||||
<div class='sm:max-w-[400px] mx-auto'>
|
||||
<div
|
||||
class='my-0 rounded-lg px-5 pb-12 pt-5 text-left sm:pb-16 sm:pt-0 sm:text-center'
|
||||
>
|
||||
<div class='mx-auto sm:max-w-[420px]'>
|
||||
<div class='hidden sm:block'><Icon icon='bell' /></div>
|
||||
<h2 class='text-3xl mb-1 font-medium hidden sm:block'>Upcoming</h2>
|
||||
<p class='text-gray-600 mb-0 sm:mb-5'>Please check back later or subscribe below.</p>
|
||||
<h2 class='text-3xl font-semibold sm:mb-1 sm:font-medium'>Upcoming</h2>
|
||||
<p class='mb-0 inline-flex hidden text-gray-600 sm:mb-5' data-auth-required>
|
||||
You will be notified by email when the roadmap is ready.
|
||||
</p>
|
||||
<p
|
||||
class='mb-0 inline-flex text-gray-600 sm:mb-5'
|
||||
data-guest-required
|
||||
>
|
||||
Please check back later or subscribe below.
|
||||
</p>
|
||||
|
||||
<form action='https://news.roadmap.sh/subscribe' method='post' accept-charset='utf-8' captcha-form>
|
||||
<input
|
||||
type='email'
|
||||
required
|
||||
name='email'
|
||||
id='email'
|
||||
autofocus
|
||||
class='mt-1 block w-full mb-2 border-2 rounded-md py-2 sm:py-3 px-3 sm:px-3.5 text-md'
|
||||
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'
|
||||
>
|
||||
Get Notified
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
type='button'
|
||||
name='submit'
|
||||
class='font-regular text-md mt-5 w-full rounded-md bg-gray-700 px-5 py-2.5 text-center text-white hover:bg-black sm:mt-0 flex gap-1 items-center justify-center'
|
||||
aria-label='Get Notified'
|
||||
>
|
||||
<Icon icon='bell' class='h-5' /> Notify me when ready!
|
||||
</button>
|
||||
<button
|
||||
data-auth-required
|
||||
type='button'
|
||||
disabled
|
||||
name='submit'
|
||||
class='font-regular text-md mt-5 sm:mt-0 flex hidden w-full items-center justify-center gap-2 rounded-md bg-gray-300 px-5 py-2.5 text-center text-gray-800'
|
||||
aria-label='Get Notified'
|
||||
>
|
||||
Please check back later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<!-- sticky top-0 -->
|
||||
|
||||
@@ -11,15 +11,6 @@ description: 'Detailed list of best practices to make your APIs secure'
|
||||
dimensions:
|
||||
width: 968
|
||||
height: 1543.39
|
||||
sponsor:
|
||||
url: 'https://liblab.com/blog/a-big-look-at-security-in-openapi?utm_source=roadmap_apisecruity&utm_medium=edge_stack&utm_campaign=april23'
|
||||
title: 'Secure APIs in OpenAPI'
|
||||
imageUrl: 'https://i.imgur.com/ZmuZUmS.png'
|
||||
description: 'Explore OpenAPI security options, industry best practices, and steps to secure your own API.'
|
||||
event:
|
||||
category: 'SponsorClick'
|
||||
action: 'Liblab Redirect'
|
||||
label: 'API Security / Liblab Link'
|
||||
schema:
|
||||
headline: 'API Security Best Practices'
|
||||
description: 'Detailed list of best practices to make your APIs secure. Each best practice carries further details and how to implement that best practice.'
|
||||
@@ -34,4 +25,3 @@ seo:
|
||||
- 'API Security Best Practices'
|
||||
- 'API Security Checklist'
|
||||
---
|
||||
|
||||
|
||||
30
src/data/best-practices/code-review/code-review.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
jsonUrl: '/jsons/best-practices/code-review.json'
|
||||
pdfUrl: '/pdfs/best-practices/code-review.pdf'
|
||||
order: 2
|
||||
briefTitle: 'Code Reviews'
|
||||
briefDescription: 'Code Review Best Practices'
|
||||
isNew: true
|
||||
isUpcoming: false
|
||||
title: 'Code Review Best Practices'
|
||||
description: 'Detailed list of best practices for effective code reviews and quality'
|
||||
dimensions:
|
||||
width: 968
|
||||
height: 3254.98
|
||||
schema:
|
||||
headline: 'Code Review Best Practices'
|
||||
description: 'Discover the essential best practices for effective code review and improve the quality of your software development. From establishing clear objectives to providing constructive feedback, this interactive guide covers everything you need to know to optimize your code review process and ensure the delivery of high-quality code.'
|
||||
imageUrl: 'https://roadmap.sh/best-practices/code-review.png'
|
||||
datePublished: '2023-01-23'
|
||||
dateModified: '2023-01-23'
|
||||
seo:
|
||||
title: 'Code Review Best Practices'
|
||||
description: 'Discover the essential best practices for effective code review and improve the quality of your software development. From establishing clear objectives to providing constructive feedback, this interactive guide covers everything you need to know to optimize your code review process and ensure the delivery of high-quality code.'
|
||||
keywords:
|
||||
- 'code reviews'
|
||||
- 'code reviews best practices'
|
||||
- 'code reviews checklist'
|
||||
- 'codereview checklist'
|
||||
- 'quality code review'
|
||||
- 'code review process'
|
||||
---
|
||||
@@ -0,0 +1,13 @@
|
||||
# Address Author Concerns
|
||||
|
||||
In the code review process, it is essential for the reviewers not only to provide constructive feedback but also to address any questions or concerns that the author of the code may have. This enables a collaborative learning environment and ensures that both the author and the reviewer have a shared understanding of the code changes, resulting in a better final product. To make sure any questions or concerns are addressed, consider the following tips:
|
||||
|
||||
- Encourage open communication: Foster a culture where the author feels comfortable asking questions or seeking clarifications without fear of being judged. A positive, supportive atmosphere will lead to more productive discussions and better outcomes.
|
||||
|
||||
- Be accessible: Make sure you as a reviewer are available to answer questions and provide assistance when needed. This may involve setting aside specific times for code review discussions or being responsive on communication channels.
|
||||
|
||||
- Ask questions: During the code review, actively ask the author if they have any questions or concerns about the feedback provided. This can help identify potential areas of confusion and create opportunities for clarification and learning.
|
||||
|
||||
- Provide clear explanations: When giving feedback, ensure your comments are clear and concise, so the author can understand the reasoning behind your suggestions. This can help prevent misunderstandings and encourage meaningful discussions.
|
||||
|
||||
- Follow up: After the code review is completed, follow up with the author to ensure they've understood the feedback and have no lingering questions or concerns. This will help reinforce the learning process and ensure a positive code review experience for both parties.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Address Feedback Received
|
||||
|
||||
As you work through the code review process, it's important to address all the feedback you've received from your team members, be it concerns, questions, or suggestions for improvements. Doing so not only ensures that your code meets the quality and performance standards, but also builds trust and credibility with your peers. In this section, we'll discuss how to effectively address each piece of feedback, keep track of the review process, and create an open, collaborative environment for learning and growth.
|
||||
|
||||
To make sure that you've addressed all the feedback, consider the following tips:
|
||||
- Clearly acknowledge every comment or suggestion made by your reviewer, either by implementing the change or providing a convincing counter-argument.
|
||||
- Keep a checklist of all the concerns raised and mark them off as you address them, ensuring that nothing is overlooked.
|
||||
- If a reviewer's comment or concern is unclear, ask for clarification instead of making assumptions, as this will prevent misunderstandings.
|
||||
- Encourage open and transparent communication, inviting all relevant stakeholders to participate in the discussion and offer their insights.
|
||||
- Once you've addressed all feedback, update your reviewer and kindly ask them to re-review your changes, making sure they're satisfied with your responses.
|
||||
- Continuously learn from the feedback you receive and apply it to future projects, improving your skills and expertise as a developer.
|
||||