mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-16 03:41:46 +08:00
Compare commits
201 Commits
feat/cmd-k
...
feat/progr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3771326cd8 | ||
|
|
ff0e10c16c | ||
|
|
ec165d4a78 | ||
|
|
afe718ee09 | ||
|
|
4aca01a98d | ||
|
|
140282f1ff | ||
|
|
4d38d19e4f | ||
|
|
5e39417a64 | ||
|
|
03ec7ebcd9 | ||
|
|
fbb6def555 | ||
|
|
ae9e30eb73 | ||
|
|
9e89c6946b | ||
|
|
6ff83d0797 | ||
|
|
5ff131ae29 | ||
|
|
e80f88ef2c | ||
|
|
cff01c151b | ||
|
|
6ca85a41a2 | ||
|
|
1630b493b1 | ||
|
|
518ece3cab | ||
|
|
aba2fd1d35 | ||
|
|
fcd68568c2 | ||
|
|
1b5e9ffe0d | ||
|
|
b3c3e44ba2 | ||
|
|
67b49d3f87 | ||
|
|
0d3e1d31bb | ||
|
|
28a27a1c65 | ||
|
|
8c3ea21ef1 | ||
|
|
417596db36 | ||
|
|
28240162b3 | ||
|
|
6dca357782 | ||
|
|
d1fe06a4e9 | ||
|
|
97cba5681b | ||
|
|
715d2ba62b | ||
|
|
32673c21fb | ||
|
|
f0c47705cb | ||
|
|
612b91e05f | ||
|
|
b4cce42844 | ||
|
|
2c2d57ecab | ||
|
|
d05374ca68 | ||
|
|
b5c02a9aff | ||
|
|
1e3568a1c4 | ||
|
|
bdeebbc9cc | ||
|
|
510e6fd273 | ||
|
|
2ca98bbb10 | ||
|
|
49cff0c22c | ||
|
|
943bf41dc5 | ||
|
|
6c9ba75906 | ||
|
|
70976ee42a | ||
|
|
5848698abf | ||
|
|
29dd1eb21f | ||
|
|
ebe6d3c6e4 | ||
|
|
425bfea265 | ||
|
|
c58efe8d00 | ||
|
|
955d04e532 | ||
|
|
0031a9c6ba | ||
|
|
8fb778337d | ||
|
|
a48d39a863 | ||
|
|
36b2a8f2d7 | ||
|
|
00e9d44ba9 | ||
|
|
62b068a94a | ||
|
|
af926002e9 | ||
|
|
0612f9c44f | ||
|
|
fbf545c2ed | ||
|
|
c7ef97cb4f | ||
|
|
564f48540e | ||
|
|
52e729d212 | ||
|
|
bdfa7606dd | ||
|
|
056e0e8e3a | ||
|
|
879ba258b2 | ||
|
|
3d62d2689f | ||
|
|
3b7a9ca5cd | ||
|
|
ac892d2868 | ||
|
|
19bde7bb2f | ||
|
|
419b1872b8 | ||
|
|
bbeb4ee279 | ||
|
|
f2ca7d9140 | ||
|
|
70b95c6ad1 | ||
|
|
5a3f621093 | ||
|
|
631eb380fc | ||
|
|
cb9778ba15 | ||
|
|
38106a8199 | ||
|
|
226e94857b | ||
|
|
f94c701657 | ||
|
|
259109cc38 | ||
|
|
e120df30e3 | ||
|
|
43f351a943 | ||
|
|
502b8e20d5 | ||
|
|
ff5858f965 | ||
|
|
8b8ef52d98 | ||
|
|
7032bc0726 | ||
|
|
ba65dec596 | ||
|
|
78cf88fbd9 | ||
|
|
93e16d899a | ||
|
|
14060bda94 | ||
|
|
45b729d708 | ||
|
|
9023ea6298 | ||
|
|
d29176cf98 | ||
|
|
55989d8480 | ||
|
|
9c936974c7 | ||
|
|
311b4683d0 | ||
|
|
bf61697154 | ||
|
|
52818f1e34 | ||
|
|
174ea05a92 | ||
|
|
dcb4e06fea | ||
|
|
62eb6a4a01 | ||
|
|
f643f3bd9a | ||
|
|
972370e0e6 | ||
|
|
a6feb72339 | ||
|
|
c751706631 | ||
|
|
8900324234 | ||
|
|
f1b880d898 | ||
|
|
9a285d7470 | ||
|
|
15259560e0 | ||
|
|
d8afa166aa | ||
|
|
d39791257e | ||
|
|
06b7005782 | ||
|
|
bc6c933440 | ||
|
|
b965a89db3 | ||
|
|
9b82e327e2 | ||
|
|
5808125d92 | ||
|
|
f49fe258aa | ||
|
|
08df9e8c33 | ||
|
|
56e388edd8 | ||
|
|
ded75c7af1 | ||
|
|
557c426078 | ||
|
|
d61a83a0a3 | ||
|
|
7500c6c1cb | ||
|
|
b51076dd0a | ||
|
|
8010bfc832 | ||
|
|
0f80f26d17 | ||
|
|
40d25c43f4 | ||
|
|
686a7382ab | ||
|
|
88401bd7b1 | ||
|
|
1d97467c05 | ||
|
|
2388fa148b | ||
|
|
d574fccbc8 | ||
|
|
89cc55a1eb | ||
|
|
8c75354235 | ||
|
|
9eb9dc8cd8 | ||
|
|
afa28bddd3 | ||
|
|
5cf0e76765 | ||
|
|
16b3f8ff49 | ||
|
|
d2055e0f6d | ||
|
|
4010157baf | ||
|
|
75c7e83264 | ||
|
|
8ca22e0dcc | ||
|
|
2b8d1f470c | ||
|
|
c4d9651e95 | ||
|
|
813c0ebd93 | ||
|
|
e376942c8d | ||
|
|
6d91c11856 | ||
|
|
1d47b1fb7b | ||
|
|
54a9e586bf | ||
|
|
b58c2a1356 | ||
|
|
dec5e58063 | ||
|
|
b0a4130229 | ||
|
|
a06e992b8a | ||
|
|
6e1072bea9 | ||
|
|
1f9eb18bfb | ||
|
|
603bd2b107 | ||
|
|
0163d9e4f9 | ||
|
|
910579f463 | ||
|
|
d6a28a312a | ||
|
|
267a4a7be5 | ||
|
|
59111a1a90 | ||
|
|
9f5d1aef74 | ||
|
|
36eed57ec2 | ||
|
|
cc054bb24b | ||
|
|
056256015d | ||
|
|
dd5f3795ec | ||
|
|
8c29d43bef | ||
|
|
aa32258aa1 | ||
|
|
d2394aca77 | ||
|
|
6804535fe4 | ||
|
|
3852e7d96f | ||
|
|
eb852caee8 | ||
|
|
1414693e33 | ||
|
|
fbdb7e77c3 | ||
|
|
c72658938f | ||
|
|
718c582a8c | ||
|
|
12f385dffd | ||
|
|
35f500d218 | ||
|
|
44949709d1 | ||
|
|
476557db80 | ||
|
|
f7625a8250 | ||
|
|
c06c236da5 | ||
|
|
24c262282e | ||
|
|
876330522d | ||
|
|
f1c771e95c | ||
|
|
d3668b25e9 | ||
|
|
b0493c370c | ||
|
|
e67caa0ffe | ||
|
|
82a44ddfef | ||
|
|
205fe6cc23 | ||
|
|
591cac8bfa | ||
|
|
42debdeab0 | ||
|
|
0555452bf2 | ||
|
|
cc7f9d94bb | ||
|
|
51d986b86f | ||
|
|
b6c8260faf | ||
|
|
03f69c02c1 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
.idea
|
||||
|
||||
# build output
|
||||
dist/
|
||||
.output/
|
||||
|
||||
@@ -5,6 +5,7 @@ import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
|
||||
// https://astro.build/config
|
||||
@@ -45,6 +46,22 @@ export default defineConfig({
|
||||
format: 'file',
|
||||
},
|
||||
integrations: [
|
||||
{
|
||||
name: 'client-authenticated',
|
||||
hooks: {
|
||||
'astro:config:setup'(options) {
|
||||
options.addClientDirective({
|
||||
name: 'authenticated',
|
||||
entrypoint: fileURLToPath(
|
||||
new URL(
|
||||
'./src/directives/client-authenticated.mjs',
|
||||
import.meta.url
|
||||
)
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
tailwind({
|
||||
config: {
|
||||
applyBaseStyles: false,
|
||||
|
||||
20
package.json
20
package.json
@@ -21,25 +21,27 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/preact": "^2.2.0",
|
||||
"@astrojs/sitemap": "^1.3.1",
|
||||
"@astrojs/preact": "^2.2.1",
|
||||
"@astrojs/sitemap": "^1.3.3",
|
||||
"@astrojs/tailwind": "^3.1.3",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.4.1",
|
||||
"astro": "^2.5.0",
|
||||
"astro-compress": "^1.1.43",
|
||||
"@nanostores/preact": "^0.5.0",
|
||||
"astro": "^2.6.3",
|
||||
"astro-compress": "^1.1.47",
|
||||
"chart.js": "^4.3.0",
|
||||
"chartjs-plugin-datalabels": "^2.2.0",
|
||||
"jose": "^4.14.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"nanostores": "^0.8.1",
|
||||
"nanostores": "^0.9.1",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.10.12",
|
||||
"preact": "^10.14.1",
|
||||
"preact": "^10.15.1",
|
||||
"rehype-external-links": "^2.1.0",
|
||||
"roadmap-renderer": "^1.0.6",
|
||||
"tailwindcss": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.33.0",
|
||||
"@playwright/test": "^1.35.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"csv-parser": "^3.0.0",
|
||||
@@ -48,7 +50,7 @@
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.2.1",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-astro": "^0.9.0",
|
||||
"prettier-plugin-astro": "^0.10.0",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
2010
pnpm-lock.yaml
generated
2010
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/images/partners/apollo-workshop.png
Normal file
BIN
public/images/partners/apollo-workshop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
public/pdfs/roadmaps/code-review.pdf
Normal file
BIN
public/pdfs/roadmaps/code-review.pdf
Normal file
Binary file not shown.
BIN
public/pdfs/roadmaps/cpp.pdf
Normal file
BIN
public/pdfs/roadmaps/cpp.pdf
Normal file
Binary file not shown.
Binary file not shown.
BIN
public/roadmaps/cpp.png
Normal file
BIN
public/roadmaps/cpp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 773 KiB |
@@ -30,9 +30,9 @@ Roadmaps are now interactive, you can click the nodes to read more about the top
|
||||
|
||||
Here is the list of available roadmaps with more being actively worked upon.
|
||||
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend)
|
||||
- [Frontend Roadmap](https://roadmap.sh/frontend) / [Frontend Beginner Roadmap](https://roadmap.sh/frontend?r=frontend-beginner)
|
||||
- [Backend Roadmap](https://roadmap.sh/backend)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops)
|
||||
- [DevOps Roadmap](https://roadmap.sh/devops) / [DevOps Beginner Roadmap](https://roadmap.sh/devops?r=devops-beginner)
|
||||
- [Full Stack Roadmap](https://roadmap.sh/full-stack)
|
||||
- [Computer Science Roadmap](https://roadmap.sh/computer-science)
|
||||
- [QA Roadmap](https://roadmap.sh/qa)
|
||||
@@ -40,6 +40,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
|
||||
- [JavaScript Roadmap](https://roadmap.sh/javascript)
|
||||
- [TypeScript Roadmap](https://roadmap.sh/typescript)
|
||||
- [C++ Roadmap](https://roadmap.sh/cpp)
|
||||
- [React Roadmap](https://roadmap.sh/react)
|
||||
- [Vue Roadmap](https://roadmap.sh/vue)
|
||||
- [Angular Roadmap](https://roadmap.sh/angular)
|
||||
@@ -61,6 +62,7 @@ Here is the list of available roadmaps with more being actively worked upon.
|
||||
- [MongoDB Roadmap](https://roadmap.sh/mongodb)
|
||||
- [UX Design Roadmap](https://roadmap.sh/ux-design)
|
||||
- [Docker Roadmap](https://roadmap.sh/docker)
|
||||
- [Prompt Engineering Roadmap](https://roadmap.sh/prompt-engineering)
|
||||
|
||||
We have also added a new form of visual content covering best practices:
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const jsonsDir = path.join(process.cwd(), 'public/jsons');
|
||||
const childJsonDirs = fs.readdirSync(jsonsDir);
|
||||
|
||||
childJsonDirs.forEach((childJsonDir) => {
|
||||
const fullChildJsonDirPath = path.join(jsonsDir, childJsonDir);
|
||||
const jsonFiles = fs.readdirSync(fullChildJsonDirPath);
|
||||
|
||||
jsonFiles.forEach((jsonFileName) => {
|
||||
console.log(`Compressing ${jsonFileName}...`);
|
||||
|
||||
const jsonFilePath = path.join(fullChildJsonDirPath, jsonFileName);
|
||||
const json = require(jsonFilePath);
|
||||
|
||||
fs.writeFileSync(jsonFilePath, JSON.stringify(json));
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ const path = require('path');
|
||||
|
||||
const OPEN_AI_API_KEY = process.env.OPEN_AI_API_KEY;
|
||||
const ALL_ROADMAPS_DIR = path.join(__dirname, '../src/data/roadmaps');
|
||||
const ROADMAP_JSON_DIR = path.join(__dirname, '../public/jsons/roadmaps');
|
||||
|
||||
const roadmapId = process.argv[2];
|
||||
|
||||
@@ -61,9 +60,9 @@ function writeTopicContent(currTopicUrl) {
|
||||
|
||||
const roadmapTitle = roadmapId.replace(/-/g, ' ');
|
||||
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
|
||||
let prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${childTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
if (!childTopic) {
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me a brief summary for that topic. Content should be in markdown. Behave as if you are the author of the guide.`;
|
||||
prompt = `I am reading a guide about "${roadmapTitle}". I am on the topic "${parentTopic}". I want to know more about "${parentTopic}". Write me with a brief summary of that. Content should be in markdown. I already know the benefits of each so do not add benefits in the output. Also include the code examples if applicable to this topic.`;
|
||||
}
|
||||
|
||||
console.log(`Generating '${childTopic || parentTopic}'...`);
|
||||
@@ -139,7 +138,11 @@ async function writeFileForGroup(group, topicUrlToPathMapping) {
|
||||
async function run() {
|
||||
const topicUrlToPathMapping = getFilesInFolder(ROADMAP_CONTENT_DIR);
|
||||
|
||||
const roadmapJson = require(path.join(ROADMAP_JSON_DIR, `${roadmapId}.json`));
|
||||
const roadmapJson = require(path.join(
|
||||
ALL_ROADMAPS_DIR,
|
||||
`${roadmapId}/${roadmapId}`
|
||||
));
|
||||
|
||||
const groups = roadmapJson?.mockup?.controls?.control?.filter(
|
||||
(control) =>
|
||||
control.typeID === '__group__' &&
|
||||
|
||||
@@ -84,8 +84,9 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
|
||||
|
||||
const roadmap = require(path.join(
|
||||
__dirname,
|
||||
`../public/jsons/roadmaps/${roadmapId}`
|
||||
`../src/data/roadmaps/${roadmapId}/${roadmapId}`
|
||||
));
|
||||
|
||||
const controls = roadmap.mockup.controls.control;
|
||||
|
||||
// Prepare the dir tree that we will be creating and also calculate the sort orders
|
||||
|
||||
153
src/components/AccountSidebar.astro
Normal file
153
src/components/AccountSidebar.astro
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
|
||||
const { activePageId, activePageTitle } = Astro.props;
|
||||
|
||||
export interface Props {
|
||||
activePageId: string;
|
||||
activePageTitle: string;
|
||||
}
|
||||
|
||||
const sidebarLinks = [
|
||||
{
|
||||
href: '/account',
|
||||
title: 'Activity',
|
||||
id: 'activity',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'analytics',
|
||||
classes: 'h-3 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/road-card',
|
||||
title: 'Card',
|
||||
id: 'road-card',
|
||||
isNew: true,
|
||||
icon: {
|
||||
glyph: 'badge',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-profile',
|
||||
title: 'Profile',
|
||||
id: 'profile',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'user',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
href: '/account/update-password',
|
||||
title: 'Security',
|
||||
id: 'change-password',
|
||||
isNew: false,
|
||||
icon: {
|
||||
glyph: 'security',
|
||||
classes: 'h-4 w-4',
|
||||
},
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<div class='relative mb-5 block border-b p-4 shadow-inner md:hidden'>
|
||||
<button
|
||||
class='flex h-10 w-full items-center justify-between rounded-md border bg-white px-2 text-center text-sm font-medium text-gray-900'
|
||||
id='settings-menu'
|
||||
>
|
||||
{activePageTitle}
|
||||
<AstroIcon icon='dropdown' />
|
||||
</button>
|
||||
<ul
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute left-0 right-0 z-10 mt-1 hidden space-y-1.5 bg-white p-2 shadow-lg'
|
||||
>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`flex w-full items-center rounded px-3 py-1.5 text-sm text-slate-900 hover:bg-slate-200 ${
|
||||
isActive ? 'bg-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='container flex min-h-screen items-stretch'>
|
||||
<!-- Start Desktop Sidebar -->
|
||||
<aside class='hidden w-44 shrink-0 border-r border-slate-200 py-10 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
{
|
||||
sidebarLinks.map((sidebarLink) => {
|
||||
const isActive = activePageId === sidebarLink.id;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={sidebarLink.href}
|
||||
class={`font-regular flex w-full items-center border-r-2 px-2 py-1.5 text-sm ${
|
||||
isActive
|
||||
? 'border-r-black bg-gray-100 text-black'
|
||||
: 'border-r-transparent text-gray-500 hover:border-r-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span class='flex flex-grow items-center'>
|
||||
<AstroIcon
|
||||
icon={sidebarLink.icon.glyph}
|
||||
class={`${sidebarLink.icon.classes} mr-2`}
|
||||
/>
|
||||
{sidebarLink.title}
|
||||
</span>
|
||||
|
||||
{sidebarLink.isNew && !isActive && (
|
||||
<span class='relative mr-1 flex items-center'>
|
||||
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
|
||||
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<!-- /End Desktop Sidebar -->
|
||||
|
||||
<div class='grow px-0 py-0 md:px-10 md:py-10'>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const menuButton = document.getElementById('settings-menu');
|
||||
const menuDropdown = document.getElementById('settings-menu-dropdown');
|
||||
|
||||
menuButton?.addEventListener('click', () => {
|
||||
menuDropdown?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton?.contains(e.target as Node)) {
|
||||
menuDropdown?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
56
src/components/Activity/ActivityCounters.tsx
Normal file
56
src/components/Activity/ActivityCounters.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
type ActivityCountersType = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
|
||||
type ActivityCounterType = {
|
||||
text: string;
|
||||
count: string;
|
||||
};
|
||||
|
||||
function ActivityCounter(props: ActivityCounterType) {
|
||||
const { text, count } = props;
|
||||
|
||||
return (
|
||||
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
|
||||
<h2 class="text-base sm:text-5xl font-bold">
|
||||
{count}
|
||||
</h2>
|
||||
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityCounters(props: ActivityCountersType) {
|
||||
const { done, learning, streak } = props;
|
||||
|
||||
return (
|
||||
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
|
||||
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
|
||||
<ActivityCounter
|
||||
text={'Topics Completed'}
|
||||
count={`${done?.total || 0}`}
|
||||
/>
|
||||
|
||||
<ActivityCounter
|
||||
text={'Currently Learning'}
|
||||
count={`${learning?.total || 0}`}
|
||||
/>
|
||||
|
||||
<ActivityCounter
|
||||
text={'Visit Streak'}
|
||||
count={`${streak?.count || 0}d`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
src/components/Activity/ActivityPage.tsx
Normal file
249
src/components/Activity/ActivityPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Chart as ChartJS, ChartTypeRegistry } from 'chart.js/auto';
|
||||
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ActivityCounters } from './ActivityCounters';
|
||||
import { ResourceProgress } from './ResourceProgress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { EmptyActivity } from './EmptyActivity';
|
||||
|
||||
type ActivityResponse = {
|
||||
done: {
|
||||
today: number;
|
||||
total: number;
|
||||
};
|
||||
learning: {
|
||||
today: number;
|
||||
total: number;
|
||||
roadmaps: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
total: number;
|
||||
skipped: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
bestPractices: {
|
||||
title: string;
|
||||
id: string;
|
||||
learning: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: string;
|
||||
}[];
|
||||
};
|
||||
streak: {
|
||||
count: number;
|
||||
firstVisitAt: Date | null;
|
||||
lastVisitAt: Date | null;
|
||||
};
|
||||
activity: {
|
||||
type: 'done' | 'learning' | 'pending' | 'skipped';
|
||||
createdAt: Date;
|
||||
metadata: {
|
||||
resourceId?: string;
|
||||
resourceType?: 'roadmap' | 'best-practice';
|
||||
topicId?: string;
|
||||
topicLabel?: string;
|
||||
resourceTitle?: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
type ChartLegendItem = {
|
||||
title: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function ActivityPage() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [activity, setActivity] = useState<ActivityResponse>();
|
||||
const [chartLegend, setChartLegend] = useState<ChartLegendItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadActivity() {
|
||||
const { error, response } = await httpGet<ActivityResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-stats`
|
||||
);
|
||||
|
||||
if (!response || error) {
|
||||
console.error('Error loading activity');
|
||||
console.error(error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setActivity(response);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const learningRoadmaps = activity?.learning.roadmaps || [];
|
||||
const learningBestPractices = activity?.learning.bestPractices || [];
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return {
|
||||
labels: [...learningRoadmaps, ...learningBestPractices].map(resource => resource.title),
|
||||
data: [...learningRoadmaps, ...learningBestPractices].map(resource => resource.done)
|
||||
}
|
||||
}, [activity])
|
||||
|
||||
useEffect(() => {
|
||||
let chart: ChartJS<"pie", number[], string> | null = null
|
||||
const ctx = canvasRef.current?.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chart) {
|
||||
chart = new ChartJS(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: [{
|
||||
data: chartData.data,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const legendItems = chart?.legend?.legendItems || []
|
||||
const enrichedLegendItems = legendItems.map((item, index) => {
|
||||
return {
|
||||
title: item.text,
|
||||
color: item.fillStyle?.toString() || ''
|
||||
}
|
||||
})
|
||||
console.log(enrichedLegendItems)
|
||||
setChartLegend(enrichedLegendItems)
|
||||
|
||||
return () => {
|
||||
chart?.destroy();
|
||||
};
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityCounters
|
||||
done={activity?.done || { today: 0, total: 0 }}
|
||||
learning={activity?.learning || { today: 0, total: 0 }}
|
||||
streak={activity?.streak || { count: 0 }}
|
||||
/>
|
||||
|
||||
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
<div className="bg-white shadow-lg rounded-2xl p-8">
|
||||
<h2 className="font-medium">Knowledge Structure</h2>
|
||||
<div className="grid grid-cols-4 gap-5 mt-6">
|
||||
<div className="w-full aspect-square flex items-center justify-center h-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<div className="flex flex-col gap-1.5 justify-center h-full">
|
||||
{chartLegend.map((data) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
style={{
|
||||
background: `${data.color}`
|
||||
}}
|
||||
className="w-3 h-3 rounded-full"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">{data.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
|
||||
{learningRoadmaps.length === 0 &&
|
||||
learningBestPractices.length === 0 && <EmptyActivity />}
|
||||
|
||||
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
|
||||
<>
|
||||
<h2 class="mb-3 text-xs uppercase text-gray-400">
|
||||
Continue Following
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3">
|
||||
{learningRoadmaps
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
const updatedAtB = new Date(b.updatedAt);
|
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
||||
})
|
||||
.map((roadmap) => (
|
||||
<ResourceProgress
|
||||
doneCount={roadmap.done || 0}
|
||||
learningCount={roadmap.learning || 0}
|
||||
totalCount={roadmap.total || 0}
|
||||
skippedCount={roadmap.skipped || 0}
|
||||
resourceId={roadmap.id}
|
||||
resourceType={'roadmap'}
|
||||
updatedAt={roadmap.updatedAt}
|
||||
title={roadmap.title}
|
||||
onCleared={() => {
|
||||
pageProgressMessage.set('Updating activity');
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{learningBestPractices
|
||||
.sort((a, b) => {
|
||||
const updatedAtA = new Date(a.updatedAt);
|
||||
const updatedAtB = new Date(b.updatedAt);
|
||||
|
||||
return updatedAtB.getTime() - updatedAtA.getTime();
|
||||
})
|
||||
.map((bestPractice) => (
|
||||
<ResourceProgress
|
||||
doneCount={bestPractice.done || 0}
|
||||
totalCount={bestPractice.total || 0}
|
||||
learningCount={bestPractice.learning || 0}
|
||||
resourceId={bestPractice.id}
|
||||
skippedCount={bestPractice.skipped || 0}
|
||||
resourceType={'best-practice'}
|
||||
title={bestPractice.title}
|
||||
updatedAt={bestPractice.updatedAt}
|
||||
onCleared={() => {
|
||||
pageProgressMessage.set('Updating activity');
|
||||
loadActivity().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/Activity/EmptyActivity.tsx
Normal file
27
src/components/Activity/EmptyActivity.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
|
||||
export function EmptyActivity() {
|
||||
return (
|
||||
<div class="rounded-md">
|
||||
<div class="flex flex-col items-center p-7 text-center">
|
||||
<img
|
||||
alt="no roadmaps"
|
||||
src={RoadmapIcon}
|
||||
class="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
|
||||
/>
|
||||
<h2 class="text-lg sm:text-xl font-bold">No Progress</h2>
|
||||
<p className="my-1 sm:my-2 max-w-[400px] text-gray-500 text-sm sm:text-base">
|
||||
Progress will appear here as you start tracking your{' '}
|
||||
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
|
||||
Roadmaps
|
||||
</a>{' '}
|
||||
or{' '}
|
||||
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
|
||||
Best Practices
|
||||
</a>{' '}
|
||||
progress.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/Activity/ResourceProgress.tsx
Normal file
139
src/components/Activity/ResourceProgress.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
|
||||
type ResourceProgressType = {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
totalCount: number;
|
||||
doneCount: number;
|
||||
learningCount: number;
|
||||
skippedCount: number;
|
||||
onCleared: () => void;
|
||||
};
|
||||
|
||||
export function ResourceProgress(props: ResourceProgressType) {
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
|
||||
const {
|
||||
updatedAt,
|
||||
resourceType,
|
||||
resourceId,
|
||||
title,
|
||||
totalCount,
|
||||
learningCount,
|
||||
doneCount,
|
||||
skippedCount,
|
||||
onCleared,
|
||||
} = props;
|
||||
|
||||
async function clearProgress() {
|
||||
setIsClearing(true);
|
||||
const { error, response } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-clear-resource-progress`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
alert('Error clearing progress. Please try again.');
|
||||
console.error(error);
|
||||
setIsClearing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(`${resourceType}-${resourceId}-progress`);
|
||||
console.log(`${resourceType}-${resourceId}-progress`);
|
||||
setIsClearing(false);
|
||||
setIsConfirming(false);
|
||||
onCleared();
|
||||
}
|
||||
|
||||
const url =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}`
|
||||
: `/best-practices/${resourceId}`;
|
||||
|
||||
const totalMarked = doneCount + skippedCount;
|
||||
const progressPercentage = Math.round((totalMarked / totalCount) * 100);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
href={url}
|
||||
className="group relative flex cursor-pointer items-center rounded-t-md border p-3 text-gray-600 hover:border-gray-300 hover:text-black"
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10`}
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
}}
|
||||
></span>
|
||||
<span className="relative flex-1 cursor-pointer truncate">
|
||||
{title}
|
||||
</span>
|
||||
<span className="ml-1 cursor-pointer text-sm text-gray-400">
|
||||
{getRelativeTimeString(updatedAt)}
|
||||
</span>
|
||||
</a>
|
||||
<p className="sm:space-between flex flex-row items-start rounded-b-md border border-t-0 px-2 py-2 text-xs text-gray-500">
|
||||
<span className="hidden flex-1 gap-1 sm:flex">
|
||||
{doneCount > 0 && (
|
||||
<>
|
||||
<span>{doneCount} done</span> •
|
||||
</>
|
||||
)}
|
||||
{learningCount > 0 && (
|
||||
<>
|
||||
<span>{learningCount} in progress</span> •
|
||||
</>
|
||||
)}
|
||||
{skippedCount > 0 && (
|
||||
<>
|
||||
<span>{skippedCount} skipped</span> •
|
||||
</>
|
||||
)}
|
||||
<span>{totalCount} total</span>
|
||||
</span>
|
||||
{!isConfirming && (
|
||||
<button
|
||||
className="text-red-500 hover:text-red-800"
|
||||
onClick={() => setIsConfirming(true)}
|
||||
disabled={isClearing}
|
||||
>
|
||||
{!isClearing && (
|
||||
<>
|
||||
Clear Progress <span>×</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isClearing && 'Processing...'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isConfirming && (
|
||||
<span>
|
||||
Are you sure?{' '}
|
||||
<button
|
||||
onClick={clearProgress}
|
||||
className="ml-1 mr-1 text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
Yes
|
||||
</button>{' '}
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
className="text-red-500 underline hover:text-red-800"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,10 @@ const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
|
||||
// Log the user in and reload the page
|
||||
if (response?.token) {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.reload();
|
||||
|
||||
return;
|
||||
|
||||
@@ -59,7 +59,10 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -57,7 +57,10 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
119
src/components/AuthenticationFlow/LinkedInButton.tsx
Normal file
119
src/components/AuthenticationFlow/LinkedInButton.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import LinkedIn from '../../icons/linkedin.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type LinkedInButtonProps = {};
|
||||
|
||||
const LINKEDIN_REDIRECT_AT = 'linkedInRedirectAt';
|
||||
const LINKEDIN_LAST_PAGE = 'linkedInLastPage';
|
||||
|
||||
export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : LinkedIn;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'linkedin') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const linkedInRedirectAt = localStorage.getItem(LINKEDIN_REDIRECT_AT);
|
||||
const lastPageBeforeLinkedIn = localStorage.getItem(LINKEDIN_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (linkedInRedirectAt && lastPageBeforeLinkedIn) {
|
||||
const socialRedirectAtTime = parseInt(linkedInRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeLinkedIn;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-linkedin-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(LINKEDIN_LAST_PAGE, window.location.pathname);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with LinkedIn
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import EmailLoginForm from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
import { LinkedInButton } from './LinkedInButton';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
@@ -19,6 +20,7 @@ import { GoogleButton } from './GoogleButton';
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
<LinkedInButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
const [code, setCode] = useState('');
|
||||
@@ -53,7 +53,10 @@ export default function ResetPasswordForm() {
|
||||
}
|
||||
|
||||
const token = response.token;
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ export function TriggerVerifyAccount() {
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
path: '/',
|
||||
expires: 30,
|
||||
});
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -32,8 +32,10 @@ function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
// Prepares the UI for the user who is logged in
|
||||
function handleGuest() {
|
||||
const authenticatedRoutes = [
|
||||
'/settings/update-profile',
|
||||
'/settings/update-password',
|
||||
'/account/update-profile',
|
||||
'/account/update-password',
|
||||
'/account/road-card',
|
||||
'/account',
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import BestPracticeHint from './BestPracticeHint.astro';
|
||||
import ProgressHelpPopup from './ProgressHelpPopup.astro';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -15,6 +16,7 @@ const isBestPracticeReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<LoginPopup />
|
||||
<ProgressHelpPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
---
|
||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
export interface Props {
|
||||
bestPracticeId: string;
|
||||
}
|
||||
---
|
||||
|
||||
<div class='mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 sm:-mb-[65px]'>
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
<div class='hidden sm:flex justify-between px-2 bg-white items-center rounded-md p-1.5'>
|
||||
<p class='text-sm'>
|
||||
<span class='text-yellow-900 bg-yellow-200 py-0.5 px-1 text-xs rounded-sm font-medium uppercase mr-0.5'>Tip</span>
|
||||
Click the best practices for details and resources
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mobile - Roadmap resources alert -->
|
||||
<p class='block sm:hidden text-sm border border-yellow-500 text-yellow-700 rounded-md py-1.5 px-2 bg-white relative'>
|
||||
Click the best practices for details and resources
|
||||
</p>
|
||||
<ResourceProgressStats />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
---
|
||||
|
||||
<div class='recaptcha-field mb-2'></div>
|
||||
<input type='hidden' name='g-recaptcha-response' class='recaptcha-response' />
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<script src='./captcha.js'></script>
|
||||
|
||||
<script is:inline>
|
||||
window.onCaptchaLoad = function () {
|
||||
if (!window.grecaptcha) {
|
||||
console.warn('window.grecaptcha is not defined');
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaFields = document.querySelectorAll('.recaptcha-field');
|
||||
|
||||
// render recaptcha on fields
|
||||
recaptchaFields.forEach((field) => {
|
||||
// If captcha already rendered for this field
|
||||
if (field.hasAttribute('data-recaptcha-id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedId = window.grecaptcha.render(field, {
|
||||
sitekey: '6Ldn2YsjAAAAABlUxNxukAuDAUIuZIhO0hRVxzJW',
|
||||
});
|
||||
|
||||
field.setAttribute('data-recaptcha-id', renderedId);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<script
|
||||
src='https://www.google.com/recaptcha/api.js?onload=onCaptchaLoad&render=explicit'
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
@@ -1,49 +0,0 @@
|
||||
class Captcha {
|
||||
constructor() {
|
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this);
|
||||
this.bindValidation = this.bindValidation.bind(this);
|
||||
this.validateCaptchaBeforeSubmit =
|
||||
this.validateCaptchaBeforeSubmit.bind(this);
|
||||
}
|
||||
|
||||
validateCaptchaBeforeSubmit(e) {
|
||||
const target = e.target;
|
||||
const captchaField = target.querySelector('.recaptcha-field');
|
||||
|
||||
if (captchaField) {
|
||||
const captchaId = captchaField.dataset.recaptchaId;
|
||||
const captchaResponse = window.grecaptcha.getResponse(captchaId);
|
||||
|
||||
// If valid captcha is not present, prevent form submission
|
||||
if (!captchaResponse) {
|
||||
e.preventDefault();
|
||||
alert('Please verify that you are human first');
|
||||
return false;
|
||||
}
|
||||
|
||||
target.querySelector('.recaptcha-response').value = captchaResponse;
|
||||
}
|
||||
|
||||
target.closest('.popup').classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
|
||||
bindValidation() {
|
||||
const forms = document.querySelectorAll('[captcha-form]');
|
||||
|
||||
forms.forEach((form) => {
|
||||
form.addEventListener('submit', this.validateCaptchaBeforeSubmit);
|
||||
});
|
||||
}
|
||||
|
||||
onDOMLoaded() {
|
||||
this.bindValidation();
|
||||
}
|
||||
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
const captcha = new Captcha();
|
||||
captcha.init();
|
||||
204
src/components/CommandMenu/CommandMenu.tsx
Normal file
204
src/components/CommandMenu/CommandMenu.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import BestPracticesIcon from '../../icons/best-practices.svg';
|
||||
import GuideIcon from '../../icons/guide.svg';
|
||||
import HomeIcon from '../../icons/home.svg';
|
||||
import RoadmapIcon from '../../icons/roadmap.svg';
|
||||
import UserIcon from '../../icons/user.svg';
|
||||
import VideoIcon from '../../icons/video.svg';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
|
||||
type PageType = {
|
||||
url: string;
|
||||
title: string;
|
||||
group: string;
|
||||
icon?: string;
|
||||
isProtected?: boolean;
|
||||
};
|
||||
|
||||
const defaultPages: PageType[] = [
|
||||
{ url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
|
||||
{
|
||||
url: '/account',
|
||||
title: 'Account',
|
||||
group: 'Pages',
|
||||
icon: UserIcon,
|
||||
isProtected: true,
|
||||
},
|
||||
{ url: '/roadmaps', title: 'Roadmaps', group: 'Pages', icon: RoadmapIcon },
|
||||
{
|
||||
url: '/best-practices',
|
||||
title: 'Best Practices',
|
||||
group: 'Pages',
|
||||
icon: BestPracticesIcon,
|
||||
},
|
||||
{ url: '/guides', title: 'Guides', group: 'Pages', icon: GuideIcon },
|
||||
{ url: '/videos', title: 'Videos', group: 'Pages', icon: VideoIcon },
|
||||
];
|
||||
|
||||
function shouldShowPage(page: PageType) {
|
||||
const isUser = isLoggedIn();
|
||||
|
||||
return !page.isProtected || isUser;
|
||||
}
|
||||
|
||||
export function CommandMenu() {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLInputElement>(null);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [allPages, setAllPages] = useState<PageType[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<PageType[]>(defaultPages);
|
||||
const [searchedText, setSearchedText] = useState('');
|
||||
const [activeCounter, setActiveCounter] = useState(0);
|
||||
|
||||
useKeydown('mod_k', () => {
|
||||
setIsActive(true);
|
||||
});
|
||||
|
||||
useOutsideClick(modalRef, () => {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function handleToggleTopic(e: any) {
|
||||
setIsActive(true);
|
||||
}
|
||||
|
||||
getAllPages();
|
||||
window.addEventListener(`command.k`, handleToggleTopic);
|
||||
return () => {
|
||||
window.removeEventListener(`command.k`, handleToggleTopic);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current.focus();
|
||||
}, [isActive]);
|
||||
|
||||
async function getAllPages() {
|
||||
if (allPages.length > 0) {
|
||||
return allPages;
|
||||
}
|
||||
const { error, response } = await httpGet<PageType[]>(`/pages.json`);
|
||||
if (!response) {
|
||||
return defaultPages.filter(shouldShowPage);
|
||||
}
|
||||
|
||||
setAllPages([...defaultPages, ...response].filter(shouldShowPage));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchedText) {
|
||||
setSearchResults(defaultPages.filter(shouldShowPage));
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSearchText = searchedText.trim().toLowerCase();
|
||||
getAllPages().then((unfilteredPages = defaultPages) => {
|
||||
const filteredPages = unfilteredPages
|
||||
.filter((currPage: PageType) => {
|
||||
return (
|
||||
currPage.title.toLowerCase().indexOf(normalizedSearchText) !== -1
|
||||
);
|
||||
})
|
||||
.slice(0, 10);
|
||||
|
||||
setActiveCounter(0);
|
||||
setSearchResults(filteredPages);
|
||||
});
|
||||
}, [searchedText]);
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 z-50 flex h-full justify-center overflow-y-auto overflow-x-hidden bg-black/50">
|
||||
<div className="relative top-0 h-full w-full max-w-lg p-2 sm:top-20 md:h-auto">
|
||||
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autofocus={true}
|
||||
type="text"
|
||||
value={searchedText}
|
||||
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
|
||||
placeholder="Search roadmaps, guides or pages .."
|
||||
autocomplete="off"
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
setSearchedText(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const canGoNext = activeCounter < searchResults.length - 1;
|
||||
setActiveCounter(canGoNext ? activeCounter + 1 : 0);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
const canGoPrev = activeCounter > 0;
|
||||
setActiveCounter(
|
||||
canGoPrev ? activeCounter - 1 : searchResults.length - 1
|
||||
);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
setSearchedText('');
|
||||
setIsActive(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
const activePage = searchResults[activeCounter];
|
||||
if (activePage) {
|
||||
window.location.href = activePage.url;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="px-2 py-2">
|
||||
<div className="flex flex-col">
|
||||
{searchResults.length === 0 && (
|
||||
<div class="p-5 text-center text-sm text-gray-400">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.map((page, counter) => {
|
||||
const prevPage = searchResults[counter - 1];
|
||||
const groupChanged = prevPage && prevPage.group !== page.group;
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupChanged && (
|
||||
<div class="border-b border-gray-100"></div>
|
||||
)}
|
||||
<a
|
||||
class={`flex w-full items-center rounded p-2 text-sm ${
|
||||
counter === activeCounter ? 'bg-gray-100' : ''
|
||||
}`}
|
||||
onMouseOver={() => setActiveCounter(counter)}
|
||||
href={page.url}
|
||||
>
|
||||
{!page.icon && (
|
||||
<span class="mr-2 text-gray-400">{page.group}</span>
|
||||
)}
|
||||
{page.icon && (
|
||||
<img src={page.icon} class="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{page.title}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/FeaturedItems/FavoriteIcon.tsx
Normal file
45
src/components/FeaturedItems/FavoriteIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
type FavoriteIconProps = {
|
||||
isFavorite?: boolean;
|
||||
};
|
||||
|
||||
export function FavoriteIcon(props: FavoriteIconProps) {
|
||||
const { isFavorite } = props;
|
||||
|
||||
if (!isFavorite) {
|
||||
return (
|
||||
<svg
|
||||
width="8"
|
||||
height="10"
|
||||
viewBox="0 0 8 10"
|
||||
fill="none"
|
||||
className="h-3.5 w-3.5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5ZM5.93682 1.25C6.42732 1.25 6.83382 1.632 6.86382 2.122L7.24932 8.506C7.25216 8.55018 7.24229 8.59425 7.22089 8.63301C7.19949 8.67176 7.16745 8.70359 7.12854 8.72472C7.08964 8.74585 7.0455 8.75542 7.00134 8.75228C6.95718 8.74914 6.91484 8.73343 6.87932 8.707L4.27582 6.783C4.19591 6.72397 4.09917 6.69211 3.99982 6.69211C3.90047 6.69211 3.80373 6.72397 3.72382 6.783L1.11982 8.707C1.0843 8.73343 1.04196 8.74914 0.9978 8.75228C0.953639 8.75542 0.909502 8.74585 0.8706 8.72472C0.831697 8.70359 0.799653 8.67176 0.778252 8.63301C0.756851 8.59425 0.746986 8.55018 0.749822 8.506L1.13632 2.122C1.16632 1.632 1.57232 1.25 2.06282 1.25H5.93682Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="8"
|
||||
height="10"
|
||||
viewBox="0 0 8 10"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
import AstroIcon from '../AstroIcon.astro';
|
||||
import { MarkFavorite } from './MarkFavorite';
|
||||
export interface FeaturedItemType {
|
||||
isUpcoming?: boolean;
|
||||
isNew?: boolean;
|
||||
@@ -13,23 +15,29 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
|
||||
<a
|
||||
class:list={[
|
||||
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100',
|
||||
'group border border-slate-800 bg-slate-900 p-2.5 sm:p-3.5 block no-underline rounded-lg relative text-slate-400 font-regular text-md hover:border-slate-600 hover:text-slate-100 overflow-hidden',
|
||||
{
|
||||
'opacity-50': isUpcoming,
|
||||
},
|
||||
]}
|
||||
href={url}
|
||||
>
|
||||
<span class='text-slate-400'>
|
||||
<span class='relative z-20 text-slate-400'>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
<MarkFavorite
|
||||
resourceId={url.split('/').pop()!}
|
||||
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
|
||||
client:load
|
||||
/>
|
||||
|
||||
{
|
||||
isNew && (
|
||||
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-purple-300 flex items-center'>
|
||||
<span class='flex h-2 w-2 mr-1.5'>
|
||||
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-purple-400 opacity-75' />
|
||||
<span class='relative inline-flex rounded-full h-2 w-2 bg-purple-500' />
|
||||
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-purple-300'>
|
||||
<span class='mr-1.5 flex h-2 w-2'>
|
||||
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-purple-400 opacity-75' />
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
|
||||
</span>
|
||||
New
|
||||
</span>
|
||||
@@ -38,13 +46,17 @@ const { isUpcoming = false, isNew = false, text, url } = Astro.props;
|
||||
|
||||
{
|
||||
isUpcoming && (
|
||||
<span class='absolute bottom-1.5 right-2 text-xs font-medium rounded-br rounded-tl text-slate-500 flex items-center'>
|
||||
<span class='flex h-2 w-2 mr-1.5'>
|
||||
<span class='animate-ping absolute inline-flex h-2 w-2 rounded-full bg-slate-500 opacity-75' />
|
||||
<span class='relative inline-flex rounded-full h-2 w-2 bg-slate-600' />
|
||||
<span class='absolute bottom-1.5 right-2 flex items-center rounded-br rounded-tl text-xs font-medium text-slate-500'>
|
||||
<span class='mr-1.5 flex h-2 w-2'>
|
||||
<span class='absolute inline-flex h-2 w-2 animate-ping rounded-full bg-slate-500 opacity-75' />
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-slate-600' />
|
||||
</span>
|
||||
Upcoming
|
||||
</span>
|
||||
)
|
||||
}
|
||||
<span
|
||||
data-progress
|
||||
class='absolute bottom-0 left-0 top-0 z-10 w-0 bg-[#172a3a] transition-[width] duration-300'
|
||||
></span>
|
||||
</a>
|
||||
|
||||
93
src/components/FeaturedItems/MarkFavorite.tsx
Normal file
93
src/components/FeaturedItems/MarkFavorite.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { FavoriteIcon } from './FavoriteIcon';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type MarkFavoriteType = {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
favorite?: boolean;
|
||||
};
|
||||
|
||||
export function MarkFavorite({ resourceId, resourceType, favorite }: MarkFavoriteType) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(favorite ?? false);
|
||||
|
||||
async function toggleFavoriteHandler(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await httpPatch<{ status: 'ok' }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-mark-favorite`,
|
||||
{
|
||||
resourceType,
|
||||
resourceId,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsLoading(false);
|
||||
return alert('Failed to update favorite status');
|
||||
}
|
||||
|
||||
// Dispatching an event instead of setting the state because
|
||||
// MarkFavorite component is used in the HeroSection as well
|
||||
// as featured items section. We will let the custom event
|
||||
// listener set the update `useEffect`
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId,
|
||||
resourceType,
|
||||
isFavorite: !isFavorite,
|
||||
},
|
||||
})
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent('refresh-favorites', {}));
|
||||
|
||||
setIsFavorite(!isFavorite);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (e: Event) => {
|
||||
const {
|
||||
resourceId: id,
|
||||
resourceType: type,
|
||||
isFavorite: fav,
|
||||
} = (e as CustomEvent).detail;
|
||||
if (id === resourceId && type === resourceType) {
|
||||
setIsFavorite(fav);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mark-favorite', listener);
|
||||
return () => {
|
||||
window.removeEventListener('mark-favorite', listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleFavoriteHandler}
|
||||
tabIndex={-1}
|
||||
className={`${
|
||||
isFavorite ? '' : 'opacity-30 hover:opacity-100'
|
||||
} absolute right-1.5 top-1.5 z-30 focus:outline-0`}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <FavoriteIcon isFavorite={isFavorite} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,13 @@ import './FrameRenderer.css';
|
||||
export interface Props {
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceId: string;
|
||||
jsonUrl: string;
|
||||
dimensions?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
|
||||
const { resourceId, resourceType, dimensions = null } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -22,7 +21,6 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
|
||||
: null}
|
||||
data-resource-type={resourceType}
|
||||
data-resource-id={resourceId}
|
||||
data-json-url={jsonUrl}
|
||||
>
|
||||
<div id='resource-loader'>
|
||||
<Loader />
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
refreshProgressCounters,
|
||||
renderResourceProgress,
|
||||
renderTopicProgress,
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
resourceType: ResourceType | string;
|
||||
jsonUrl: string;
|
||||
loaderHTML: string | null;
|
||||
|
||||
@@ -28,8 +34,10 @@ export class Renderer {
|
||||
this.onDOMLoaded = this.onDOMLoaded.bind(this);
|
||||
this.jsonToSvg = this.jsonToSvg.bind(this);
|
||||
this.handleSvgClick = this.handleSvgClick.bind(this);
|
||||
this.handleSvgRightClick = this.handleSvgRightClick.bind(this);
|
||||
this.prepareConfig = this.prepareConfig.bind(this);
|
||||
this.switchRoadmap = this.switchRoadmap.bind(this);
|
||||
this.updateTopicStatus = this.updateTopicStatus.bind(this);
|
||||
}
|
||||
|
||||
get loaderEl() {
|
||||
@@ -51,7 +59,6 @@ export class Renderer {
|
||||
|
||||
this.resourceType = dataset.resourceType!;
|
||||
this.resourceId = dataset.resourceId!;
|
||||
this.jsonUrl = dataset.jsonUrl!;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -130,13 +137,19 @@ export class Renderer {
|
||||
this.trackVisit();
|
||||
|
||||
if (roadmapType) {
|
||||
this.switchRoadmap(`/jsons/roadmaps/${roadmapType}.json`);
|
||||
this.switchRoadmap(`/${roadmapType}.json`);
|
||||
} else {
|
||||
this.jsonToSvg(this.jsonUrl);
|
||||
this.jsonToSvg(
|
||||
this.resourceType === 'roadmap'
|
||||
? `/${this.resourceId}.json`
|
||||
: `/best-practices/${this.resourceId}.json`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
switchRoadmap(newJsonUrl: string) {
|
||||
this.containerEl?.setAttribute('style', '');
|
||||
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
||||
|
||||
// Update the URL and attach the new roadmap type
|
||||
@@ -145,25 +158,62 @@ export class Renderer {
|
||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||
|
||||
url.searchParams.delete(type);
|
||||
url.searchParams.set(type, newJsonFileSlug!);
|
||||
|
||||
if (newJsonFileSlug !== this.resourceId) {
|
||||
url.searchParams.set(type, newJsonFileSlug!);
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
|
||||
const pageType = this.resourceType.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {});
|
||||
}
|
||||
|
||||
window.fireEvent({
|
||||
// RoadmapClick, BestPracticesClick, etc
|
||||
category: `${pageType.replace('-', '')}Click`,
|
||||
// roadmap/frontend/switch-version
|
||||
action: `${this.resourceId}/switch-version`,
|
||||
// roadmap/frontend/switch-version
|
||||
label: `${newJsonFileSlug}`,
|
||||
});
|
||||
updateTopicStatus(topicId: string, newStatus: ResourceProgressType) {
|
||||
if (!isLoggedIn()) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {
|
||||
this.containerEl?.setAttribute('style', '');
|
||||
});
|
||||
pageProgressMessage.set('Updating progress');
|
||||
updateResourceProgress(
|
||||
{
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType as ResourceType,
|
||||
topicId,
|
||||
},
|
||||
newStatus
|
||||
)
|
||||
.then(() => {
|
||||
renderTopicProgress(topicId, newStatus);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert('Something went wrong, please try again.');
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
handleSvgRightClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const isCurrentStatusDone = targetGroup.classList.contains('done');
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusDone ? 'done' : 'pending'
|
||||
);
|
||||
}
|
||||
|
||||
handleSvgClick(e: any) {
|
||||
@@ -176,7 +226,17 @@ export class Renderer {
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
if (/^ext_link/.test(groupId)) {
|
||||
window.open(`https://${groupId.replace('ext_link:', '')}`);
|
||||
const externalLink = groupId.replace('ext_link:', '');
|
||||
|
||||
if (!externalLink.startsWith('roadmap.sh')) {
|
||||
window.fireEvent({
|
||||
category: 'RoadmapExternalLink',
|
||||
action: `${this.resourceType} / ${this.resourceId}`,
|
||||
label: externalLink,
|
||||
});
|
||||
}
|
||||
|
||||
window.open(`https://${externalLink}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,6 +264,28 @@ export class Renderer {
|
||||
// Remove sorting prefix from groupId
|
||||
const normalizedGroupId = groupId.replace(/^\d+-/, '');
|
||||
|
||||
const isCurrentStatusLearning = targetGroup.classList.contains('learning');
|
||||
const isCurrentStatusSkipped = targetGroup.classList.contains('skipped');
|
||||
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusLearning ? 'learning' : 'pending'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.altKey) {
|
||||
e.preventDefault();
|
||||
this.updateTopicStatus(
|
||||
normalizedGroupId,
|
||||
!isCurrentStatusSkipped ? 'skipped' : 'pending'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(`${this.resourceType}.topic.click`, {
|
||||
detail: {
|
||||
@@ -218,7 +300,7 @@ export class Renderer {
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
window.addEventListener('click', this.handleSvgClick);
|
||||
// window.addEventListener('contextmenu', this.handleSvgClick);
|
||||
window.addEventListener('contextmenu', this.handleSvgRightClick);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const { author } = frontmatter;
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
class='text-blue-400 hover:text-blue-500 hover:underline'
|
||||
href={`https://github.com/kamranahmedse/roadmap.sh/tree/master/src/data/guides/${guide.id}.md`}
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/guides/${guide.id}.md`}
|
||||
target='_blank'>Improve this Guide</a
|
||||
>
|
||||
</p>
|
||||
|
||||
23
src/components/HeroSection/EmptyProgress.tsx
Normal file
23
src/components/HeroSection/EmptyProgress.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function EmptyProgress(props: EmptyProgressProps) {
|
||||
const {
|
||||
title = 'Start learning ..',
|
||||
message = 'Your progress and favorite roadmaps will show up here.',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-col items-start sm:items-center justify-center py-6">
|
||||
<h2 className={'mb-1 flex items-center text-lg sm:text-2xl text-gray-200'}>
|
||||
<CheckIcon additionalClasses='mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]' />
|
||||
Start learning ..
|
||||
</h2>
|
||||
<p className={'text-gray-400 text-sm sm:text-base'}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/HeroSection/FavoriteRoadmaps.tsx
Normal file
124
src/components/HeroSection/FavoriteRoadmaps.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { ProgressList } from './ProgressList';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
resourceType: 'roadmap' | 'best-practice';
|
||||
resourceTitle: string;
|
||||
isFavorite: boolean;
|
||||
done: number;
|
||||
learning: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
}[];
|
||||
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
progressList.forEach((progress) => {
|
||||
const href =
|
||||
progress.resourceType === 'best-practice'
|
||||
? `/best-practices/${progress.resourceId}`
|
||||
: `/${progress.resourceId}`;
|
||||
const element = document.querySelector(`a[href="${href}"]`);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mark-favorite', {
|
||||
detail: {
|
||||
resourceId: progress.resourceId,
|
||||
resourceType: progress.resourceType,
|
||||
isFavorite: progress.isFavorite,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const totalDone = progress.done + progress.skipped;
|
||||
const percentageDone = (totalDone / progress.total) * 100;
|
||||
|
||||
const progressBar: HTMLElement | null =
|
||||
element.querySelector('[data-progress]');
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${percentageDone}%`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function FavoriteRoadmaps() {
|
||||
const [isPreparing, setIsPreparing] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [progress, setProgress] = useState<UserProgressResponse>([]);
|
||||
const [containerOpacity, setContainerOpacity] = useState(0);
|
||||
|
||||
function showProgressContainer() {
|
||||
const heroEl = document.getElementById('hero-text')!;
|
||||
if (!heroEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroEl.classList.add('opacity-0');
|
||||
setTimeout(() => {
|
||||
heroEl.parentElement?.removeChild(heroEl);
|
||||
setIsPreparing(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setContainerOpacity(100);
|
||||
}, 50);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function loadProgress() {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response: progressList, error } =
|
||||
await httpGet<UserProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
||||
);
|
||||
|
||||
if (error || !progressList) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(progressList);
|
||||
setIsLoading(false);
|
||||
showProgressContainer();
|
||||
|
||||
// render progress on featured items
|
||||
renderProgress(progressList);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadProgress().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('refresh-favorites', loadProgress);
|
||||
return () => window.removeEventListener('refresh-favorites', loadProgress);
|
||||
}, []);
|
||||
|
||||
if (isPreparing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasProgress = progress.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress.length == 0 && <EmptyProgress />}
|
||||
{progress.length > 0 && (
|
||||
<ProgressList progress={progress} isLoading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/HeroSection/HeroSection.astro
Normal file
28
src/components/HeroSection/HeroSection.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
---
|
||||
|
||||
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
|
||||
<div
|
||||
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
|
||||
id='hero-text'
|
||||
>
|
||||
<h1
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
|
||||
>
|
||||
Developer Roadmaps
|
||||
</h1>
|
||||
|
||||
<p class='hidden px-4 text-lg text-gray-400 sm:block'>
|
||||
<span class='font-medium text-gray-400'>roadmap.sh</span> is a community effort
|
||||
to create roadmaps, guides and other educational content to help guide developers
|
||||
in picking up a path and guide their learnings.
|
||||
</p>
|
||||
|
||||
<p class='text-md block px-0 text-gray-400 sm:hidden'>
|
||||
Community created roadmaps, guides and articles to help developers grow in
|
||||
their career.
|
||||
</p>
|
||||
</div>
|
||||
<FavoriteRoadmaps client:authenticated />
|
||||
</div>
|
||||
61
src/components/HeroSection/ProgressList.tsx
Normal file
61
src/components/HeroSection/ProgressList.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { UserProgressResponse } from './FavoriteRoadmaps';
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
|
||||
import { Spinner } from '../ReactIcons/Spinner';
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function ProgressList(props: ProgressListProps) {
|
||||
const { progress, isLoading = false } = props;
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<p className="mb-4 flex items-center text-sm text-gray-400">
|
||||
{!isLoading && (
|
||||
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
|
||||
)}
|
||||
{isLoading && (
|
||||
<span className="mr-1.5">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
Your progress and favorite roadmaps.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{progress.map((resource) => {
|
||||
const url =
|
||||
resource.resourceType === 'roadmap'
|
||||
? `/${resource.resourceId}`
|
||||
: `/best-practices/${resource.resourceId}`;
|
||||
|
||||
const percentageDone =
|
||||
((resource.skipped + resource.done) / resource.total) * 100;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={resource.resourceId}
|
||||
href={url}
|
||||
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
|
||||
>
|
||||
<span className="relative z-20">{resource.resourceTitle}</span>
|
||||
|
||||
<span
|
||||
class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
|
||||
style={{ width: `${percentageDone}%` }}
|
||||
></span>
|
||||
<MarkFavorite
|
||||
resourceId={resource.resourceId}
|
||||
resourceType={resource.resourceType}
|
||||
favorite={resource.isFavorite}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,10 +24,10 @@ import Icon from '../AstroIcon.astro';
|
||||
<ul>
|
||||
<li class='px-1'>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
href='/account'
|
||||
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
>
|
||||
Settings
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li class='px-1'>
|
||||
|
||||
@@ -5,9 +5,12 @@ 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">
|
||||
<a
|
||||
class='flex items-center text-lg font-medium text-white'
|
||||
href='/'
|
||||
aria-label='roadmap.sh'
|
||||
>
|
||||
<Icon icon='logo' />
|
||||
<span class='ml-3 hidden md:block'>roadmap.sh</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
@@ -20,11 +23,20 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
>Best Practices</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='hidden lg:inline text-gray-400 hover:text-white'>Guides</a>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='hidden lg:inline text-gray-400 hover:text-white'>Videos</a>
|
||||
<kbd
|
||||
data-command-menu
|
||||
class='hidden items-center rounded-md border border-gray-800 px-2.5 py-1 text-sm text-gray-400 hover:cursor-pointer hover:bg-gray-800 sm:flex'
|
||||
>
|
||||
<Icon icon='search' class='mr-2 h-3 w-3' />
|
||||
<kbd class='mr-1 font-sans'>⌘</kbd><kbd class='font-sans'>K</kbd>
|
||||
</kbd>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
|
||||
@@ -92,11 +104,8 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
|
||||
<!-- 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 href='/account' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Account
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
@@ -110,7 +119,7 @@ import AccountDropdown from './AccountDropdown.astro';
|
||||
<li>
|
||||
<a
|
||||
data-guest-required
|
||||
href='/signup'
|
||||
href='/login'
|
||||
class='hidden text-xl text-white md:text-lg'
|
||||
>
|
||||
Login
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { handleAuthRequired } from '../Authenticator/authenticator';
|
||||
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
|
||||
import { TOKEN_COOKIE_NAME } from "../../lib/jwt";
|
||||
|
||||
export function logout() {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
@@ -34,6 +33,12 @@ function bindEvents() {
|
||||
.querySelector('[data-account-dropdown]')
|
||||
?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-command-menu]')
|
||||
?.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('command.k'));
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useIsFirstRender } from '../hooks/use-is-first-render';
|
||||
import SpinnerIcon from '../icons/spinner.svg';
|
||||
import { pageLoadingMessage } from '../stores/page';
|
||||
import { pageProgressMessage } from '../stores/page';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export interface Props {
|
||||
initialMessage: string;
|
||||
@@ -9,14 +9,20 @@ export interface Props {
|
||||
|
||||
export function PageProgress(props: Props) {
|
||||
const { initialMessage } = props;
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
|
||||
const isFirstRender = useIsFirstRender();
|
||||
const $pageLoadingMessage = useStore(pageLoadingMessage);
|
||||
const $pageProgressMessage = useStore(pageProgressMessage);
|
||||
|
||||
if (!$pageLoadingMessage) {
|
||||
if (!initialMessage || !isFirstRender) {
|
||||
return null;
|
||||
useEffect(() => {
|
||||
if ($pageProgressMessage === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage($pageProgressMessage);
|
||||
}, [$pageProgressMessage]);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -30,7 +36,7 @@ export function PageProgress(props: Props) {
|
||||
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}
|
||||
{message}
|
||||
<span className="animate-pulse">...</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,18 @@ export function PageSponsor(props: PageSponsorProps) {
|
||||
const [sponsor, setSponsor] = useState<PageSponsorType>();
|
||||
|
||||
const loadSponsor = async () => {
|
||||
const currentPath = window.location.pathname;
|
||||
if (
|
||||
currentPath === '/' ||
|
||||
currentPath === '/best-practices' ||
|
||||
currentPath === '/roadmaps' ||
|
||||
currentPath.startsWith('/guides') ||
|
||||
currentPath.startsWith('/videos') ||
|
||||
currentPath.startsWith('/account')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<V1GetSponsorResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-sponsor`,
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ export class Popup {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
popupEl.classList.remove('hidden');
|
||||
popupEl.classList.add('flex');
|
||||
const focusEl = popupEl.querySelector('[autofocus]');
|
||||
|
||||
47
src/components/ProgressHelpPopup.astro
Normal file
47
src/components/ProgressHelpPopup.astro
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Popup from './Popup/Popup.astro';
|
||||
---
|
||||
|
||||
<Popup id='progress-help' title='' subtitle=''>
|
||||
<div class='-mt-2.5'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
|
||||
Track your Progress
|
||||
</h2>
|
||||
<p class='text-sm leading-4 text-gray-600'>
|
||||
Login and use one of the options listed below.
|
||||
</p>
|
||||
|
||||
<div class='mt-4 flex flex-col gap-1.5'>
|
||||
<div class='rounded-md border px-3 py-3 text-gray-500'>
|
||||
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
|
||||
>Option 1</span
|
||||
>
|
||||
<p class='text-sm'>
|
||||
Click the roadmap topics and use <span class='underline'
|
||||
>Update Progress</span
|
||||
> dropdown to update your progress.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='rounded-md border border-yellow-300 bg-yellow-50 px-3 py-3 text-gray-500'>
|
||||
<span class='mb-1.5 block text-xs font-medium uppercase text-green-600'
|
||||
>Option 2</span
|
||||
>
|
||||
<p class='text-sm'>Use the keyboard shortcuts listed below.</p>
|
||||
|
||||
<ul class="flex flex-col gap-1 mt-3 mb-1.5">
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Right Mouse Click</kbd> to mark as Done.
|
||||
</li>
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Shift</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as in progress.
|
||||
</li>
|
||||
<li class='text-sm leading-loose'>
|
||||
<kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Option / Alt</kbd> + <kbd class="px-2 py-1.5 text-xs text-white bg-gray-900 rounded-md">Click</kbd> to mark as skipped.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
20
src/components/ReactIcons/CheckIcon.tsx
Normal file
20
src/components/ReactIcons/CheckIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
type CheckIconProps = {
|
||||
additionalClasses?: string;
|
||||
};
|
||||
|
||||
export function CheckIcon(props: CheckIconProps) {
|
||||
const { additionalClasses = 'mr-2 top-[0.5px] w-[20px] h-[20px]' } = props;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={`relative ${additionalClasses}]`}
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
src/components/ReactIcons/Spinner.tsx
Normal file
21
src/components/ReactIcons/Spinner.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export function Spinner() {
|
||||
return (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 animate-spin"
|
||||
viewBox="0 0 93 93"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
|
||||
style="fill: #404040;"
|
||||
></path>
|
||||
<path
|
||||
d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z"
|
||||
style="fill: #94a3b8;"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
68
src/components/ResourceProgressStats.astro
Normal file
68
src/components/ResourceProgressStats.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
export interface Props {
|
||||
isSecondaryBanner?: boolean;
|
||||
}
|
||||
|
||||
const { isSecondaryBanner = false } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
data-progress-nums-container
|
||||
class:list={[
|
||||
'hidden sm:flex justify-between px-2 bg-white items-center py-1.5 relative striped-loader bg-white',
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': isSecondaryBanner,
|
||||
'rounded-md': !isSecondaryBanner,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p
|
||||
class='flex text-sm opacity-0 transition-opacity duration-300'
|
||||
data-progress-nums
|
||||
>
|
||||
<span
|
||||
class='mr-2.5 rounded-sm bg-yellow-200 px-1 py-0.5 text-xs font-medium uppercase text-yellow-900'
|
||||
>
|
||||
<span data-progress-percentage>0</span>% Done
|
||||
</span>
|
||||
|
||||
<span><span data-progress-done>0</span> completed</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-learning>0</span> in progress</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-skipped>0</span> skipped</span><span
|
||||
class='mx-1.5 text-gray-400'>·</span
|
||||
>
|
||||
<span><span data-progress-total>0</span> Total</span>
|
||||
</p>
|
||||
|
||||
<button
|
||||
data-popup='progress-help'
|
||||
class='flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black'
|
||||
data-progress-nums
|
||||
>
|
||||
<AstroIcon icon='question' />
|
||||
Track Progress
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
data-progress-nums-container
|
||||
class='striped-loader relative -mb-2 flex items-center justify-between rounded-md border bg-white bg-white px-2 py-1.5 text-sm text-sm text-gray-700 sm:hidden'
|
||||
>
|
||||
<span data-progress-nums class='opacity-0 transition-opacity duration-300 text-gray-500'>
|
||||
<span data-progress-done>0</span> of <span data-progress-total>0</span> Done
|
||||
</span>
|
||||
|
||||
<button
|
||||
data-popup='progress-help'
|
||||
class='flex items-center gap-1 text-sm font-medium text-gray-500 opacity-0 transition-opacity hover:text-black'
|
||||
data-progress-nums
|
||||
>
|
||||
<AstroIcon icon='question' />
|
||||
Track Progress
|
||||
</button>
|
||||
</p>
|
||||
42
src/components/RoadCard/Editor.tsx
Normal file
42
src/components/RoadCard/Editor.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import CopyIcon from '../../icons/copy.svg';
|
||||
|
||||
type EditorProps = {
|
||||
title: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const { text, title } = props;
|
||||
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-grow flex-col overflow-hidden rounded border border-gray-300 bg-gray-50">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-gray-300 px-3 py-2">
|
||||
<span className="text-xs uppercase leading-none text-gray-400">
|
||||
{title}
|
||||
</span>
|
||||
<button className="flex items-center" onClick={() => copyText(text)}>
|
||||
{isCopied && (
|
||||
<span className="mr-1 text-xs leading-none text-gray-700">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
|
||||
<img src={CopyIcon} alt="Copy" className="inline-block h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="no-scrollbar block h-12 w-full overflow-x-auto whitespace-nowrap bg-gray-200/70 p-3 text-sm text-gray-900 focus:bg-gray-50 focus:outline-0"
|
||||
readOnly
|
||||
onClick={(e: any) => {
|
||||
e.target.select();
|
||||
copyText(e.target.value);
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</textarea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/components/RoadCard/GitHubReadmeBanner.tsx
Normal file
15
src/components/RoadCard/GitHubReadmeBanner.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export function GitHubReadmeBanner() {
|
||||
return (
|
||||
<p className="mt-3 rounded-md border p-2 text-sm w-full bg-yellow-100 border-yellow-400 text-yellow-900">
|
||||
Add this badge to your{' '}
|
||||
<a
|
||||
href="https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
GitHub profile readme.
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
172
src/components/RoadCard/RoadCardPage.tsx
Normal file
172
src/components/RoadCard/RoadCardPage.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { useAuth } from '../../hooks/use-auth';
|
||||
import CopyIcon from '../../icons/copy.svg';
|
||||
import { RoadmapSelect } from './RoadmapSelect';
|
||||
import { GitHubReadmeBanner } from './GitHubReadmeBanner';
|
||||
import { downloadImage } from '../../helper/download-image';
|
||||
import { SelectionButton } from './SelectionButton';
|
||||
import { StepCounter } from './StepCounter';
|
||||
import { Editor } from './Editor';
|
||||
|
||||
type StepLabelProps = {
|
||||
label: string;
|
||||
};
|
||||
function StepLabel(props: StepLabelProps) {
|
||||
const { label } = props;
|
||||
|
||||
return (
|
||||
<span className="mb-3 flex items-center gap-2 text-sm leading-none text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoadCardPage() {
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
const [roadmaps, setRoadmaps] = useState<string[]>([]);
|
||||
const [version, setVersion] = useState<'tall' | 'wide'>('tall');
|
||||
const [variant, setVariant] = useState<'dark' | 'light'>('dark');
|
||||
const user = useAuth();
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const badgeUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${version}/${user?.id}`
|
||||
);
|
||||
|
||||
badgeUrl.searchParams.set('variant', variant);
|
||||
if (roadmaps.length > 0) {
|
||||
badgeUrl.searchParams.set('roadmaps', roadmaps.join(','));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b pt-2 pb-4">
|
||||
<StepCounter step={1} />
|
||||
<div>
|
||||
<StepLabel label="Pick progress to show (Max. 4)" />
|
||||
|
||||
<div className="flex flex-wrap">
|
||||
<RoadmapSelect
|
||||
selectedRoadmaps={roadmaps}
|
||||
setSelectedRoadmaps={setRoadmaps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
|
||||
<StepCounter step={2} />
|
||||
<div>
|
||||
<StepLabel label="Select Mode (Dark vs Light)" />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<SelectionButton
|
||||
text={'Dark'}
|
||||
isDisabled={false}
|
||||
isSelected={variant === 'dark'}
|
||||
onClick={() => {
|
||||
setVariant('dark');
|
||||
}}
|
||||
/>
|
||||
|
||||
<SelectionButton
|
||||
text={'Light'}
|
||||
isDisabled={false}
|
||||
isSelected={variant === 'light'}
|
||||
onClick={() => {
|
||||
setVariant('light');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
|
||||
<StepCounter step={3} />
|
||||
<div>
|
||||
<StepLabel label="Select Version" />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<SelectionButton
|
||||
text={'Tall'}
|
||||
isDisabled={false}
|
||||
isSelected={version === 'tall'}
|
||||
onClick={() => {
|
||||
setVersion('tall');
|
||||
}}
|
||||
/>
|
||||
<SelectionButton
|
||||
text={'Wide'}
|
||||
isDisabled={false}
|
||||
isSelected={version === 'wide'}
|
||||
onClick={() => {
|
||||
setVersion('wide');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
|
||||
<StepCounter step={4} />
|
||||
<div class="flex-grow">
|
||||
<StepLabel label="Share your #RoadCard with others" />
|
||||
<div className={'rounded-md border bg-gray-50 p-2 text-center'}>
|
||||
<a
|
||||
href={badgeUrl.toString()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`relative block hover:cursor-pointer ${
|
||||
version === 'tall' ? ' max-w-[270px] ' : ' w-full '
|
||||
}`}
|
||||
>
|
||||
<img src={badgeUrl.toString()} alt="RoadCard" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
className="flex items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium"
|
||||
onClick={() =>
|
||||
downloadImage({
|
||||
url: badgeUrl.toString(),
|
||||
name: 'road-card',
|
||||
scale: 4,
|
||||
})
|
||||
}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
disabled={isCopied}
|
||||
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
|
||||
onClick={() => copyText(badgeUrl.toString())}
|
||||
>
|
||||
<img alt="Copy" src={CopyIcon} className="mr-1" />
|
||||
|
||||
{isCopied ? 'Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<Editor
|
||||
title={'HTML'}
|
||||
text={`<a href="https://roadmap.sh"><img src="${badgeUrl}" alt="roadmap.sh"/></a>`.trim()}
|
||||
/>
|
||||
|
||||
<Editor
|
||||
title={'Markdown'}
|
||||
text={`[](https://roadmap.sh)`.trim()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GitHubReadmeBanner />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
src/components/RoadCard/RoadmapSelect.tsx
Normal file
68
src/components/RoadCard/RoadmapSelect.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
|
||||
import { SelectionButton } from './SelectionButton';
|
||||
|
||||
type RoadmapSelectProps = {
|
||||
selectedRoadmaps: string[];
|
||||
setSelectedRoadmaps: (updatedRoadmaps: string[]) => void;
|
||||
};
|
||||
|
||||
export function RoadmapSelect(props: RoadmapSelectProps) {
|
||||
const { selectedRoadmaps, setSelectedRoadmaps } = props;
|
||||
|
||||
const [progressList, setProgressList] = useState<UserProgressResponse>();
|
||||
|
||||
const fetchProgress = async () => {
|
||||
const { response, error } = await httpGet<UserProgressResponse>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-all-progress`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProgressList(response);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProgress().finally(() => {
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const canSelectMore = selectedRoadmaps.length < 4;
|
||||
const allProgress = progressList?.filter(
|
||||
(progress) => progress.resourceType === 'roadmap'
|
||||
) || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allProgress?.length === 0 && <p className={'text-sm text-gray-400 italic'}>No progress tracked so far.</p>}
|
||||
{allProgress?.map((progress) => {
|
||||
const isSelected = selectedRoadmaps.includes(progress.resourceId);
|
||||
const canSelect = isSelected || canSelectMore;
|
||||
|
||||
return (
|
||||
<SelectionButton
|
||||
text={progress.resourceTitle}
|
||||
isDisabled={!canSelect}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
setSelectedRoadmaps(
|
||||
selectedRoadmaps.filter(
|
||||
(roadmap) => roadmap !== progress.resourceId
|
||||
)
|
||||
);
|
||||
} else if (selectedRoadmaps.length < 4) {
|
||||
setSelectedRoadmaps([...selectedRoadmaps, progress.resourceId]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/RoadCard/SelectionButton.tsx
Normal file
23
src/components/RoadCard/SelectionButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
type SelectionButtonProps = {
|
||||
text: string;
|
||||
isDisabled: boolean;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function SelectionButton(props: SelectionButtonProps) {
|
||||
const { text, isDisabled, isSelected, onClick } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`rounded-md border p-1 px-2 text-sm ${
|
||||
isSelected ? ' border-gray-500 bg-gray-300 ' : ''
|
||||
} ${
|
||||
!isDisabled ? ' cursor-pointer ' : ' cursor-not-allowed opacity-40 '
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
src/components/RoadCard/StepCounter.tsx
Normal file
17
src/components/RoadCard/StepCounter.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
type StepCounterProps = {
|
||||
step: number;
|
||||
};
|
||||
|
||||
export function StepCounter(props: StepCounterProps) {
|
||||
const { step } = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-300 text-white'
|
||||
}
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import RoadmapHint from './RoadmapHint.astro';
|
||||
import { MarkFavorite } from './FeaturedItems/MarkFavorite.tsx';
|
||||
import RoadmapNote from './RoadmapNote.astro';
|
||||
import TopicSearch from './TopicSearch/TopicSearch.astro';
|
||||
import YouTubeAlert from './YouTubeAlert.astro';
|
||||
import ProgressHelpPopup from './ProgressHelpPopup.astro';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -32,6 +34,7 @@ const isRoadmapReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<LoginPopup />
|
||||
<ProgressHelpPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
---
|
||||
import { ClearProgress } from './Activity/ClearProgress';
|
||||
import AstroIcon from './AstroIcon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import ResourceProgressStats from './ResourceProgressStats.astro';
|
||||
|
||||
export interface Props {
|
||||
roadmapId: string;
|
||||
@@ -41,40 +44,5 @@ const roadmapTitle =
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
<div
|
||||
class:list={[
|
||||
'hidden sm:flex justify-between px-2 bg-white items-center',
|
||||
{
|
||||
'rounded-bl-md rounded-br-md': hasTNSBanner,
|
||||
'rounded-md': !hasTNSBanner,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<p class='text-sm'>
|
||||
<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 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>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile - Roadmap resources alert -->
|
||||
<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
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<ResourceProgressStats isSecondaryBanner={hasTNSBanner} />
|
||||
</div>
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
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>
|
||||
226
src/components/TopicDetail/ContributionForm.tsx
Normal file
226
src/components/TopicDetail/ContributionForm.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type ContributionInputProps = {
|
||||
id: number;
|
||||
title: string;
|
||||
link: string;
|
||||
isLast: boolean;
|
||||
totalCount: number;
|
||||
onAdd: () => void;
|
||||
onRemove: () => void;
|
||||
onChange: (link: { id: number; title: string; link: string }) => void;
|
||||
};
|
||||
|
||||
function ContributionInput(props: ContributionInputProps) {
|
||||
const {
|
||||
isLast,
|
||||
totalCount,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onChange,
|
||||
id,
|
||||
title: defaultTitle,
|
||||
link: defaultLink,
|
||||
} = props;
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const [focused, setFocused] = useState('');
|
||||
const [title, setTitle] = useState(defaultTitle);
|
||||
const [link, setLink] = useState(defaultLink);
|
||||
|
||||
useEffect(() => {
|
||||
if (!titleRef?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
titleRef.current.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onChange({ id, title, link });
|
||||
}, [title, link]);
|
||||
|
||||
const canAddMore = isLast && totalCount < 5;
|
||||
|
||||
return (
|
||||
<div className="relative mb-3 rounded-md border p-3">
|
||||
<p
|
||||
className={`mb-1 text-xs uppercase ${
|
||||
focused === 'title' ? 'text-black' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Resource Title
|
||||
</p>
|
||||
<input
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
required
|
||||
className="block w-full rounded-md border p-2 text-sm focus:border-gray-400 focus:outline-none"
|
||||
placeholder="e.g. Introduction to RESTful APIs"
|
||||
onFocus={() => setFocused('title')}
|
||||
onBlur={() => setFocused('')}
|
||||
onChange={(e) => setTitle((e.target as any).value)}
|
||||
/>
|
||||
<p
|
||||
className={`mb-1 mt-3 text-xs uppercase ${
|
||||
focused === 'link' ? 'text-black' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Resource Link
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
className="block w-full rounded-md border p-2 text-sm focus:border-gray-400 focus:outline-none"
|
||||
placeholder="e.g. https://roadmap.sh/guides/some-url"
|
||||
onFocus={() => setFocused('link')}
|
||||
onBlur={() => setFocused('')}
|
||||
onChange={(e) => setLink((e.target as any).value)}
|
||||
/>
|
||||
|
||||
<div className="mb-0 mt-3 flex gap-3">
|
||||
{totalCount !== 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onRemove();
|
||||
}}
|
||||
className="rounded-md text-sm font-semibold text-red-500 underline underline-offset-2 hover:text-red-800"
|
||||
>
|
||||
- Remove Link
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canAddMore && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onAdd();
|
||||
}}
|
||||
className="rounded-md text-sm font-semibold text-gray-600 underline underline-offset-2 hover:text-black"
|
||||
>
|
||||
+ Add another Link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ContributionFormProps = {
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
topicId: string;
|
||||
onClose: (message?: string) => void;
|
||||
};
|
||||
|
||||
export function ContributionForm(props: ContributionFormProps) {
|
||||
const { onClose, resourceType, resourceId, topicId } = props;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [links, setLinks] = useState<
|
||||
{ id: number; title: string; link: string }[]
|
||||
>([
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
title: '',
|
||||
link: '',
|
||||
},
|
||||
]);
|
||||
|
||||
async function onSubmit(e: any) {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-contribute-link`,
|
||||
{
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
links,
|
||||
}
|
||||
);
|
||||
|
||||
setIsSubmitting(false);
|
||||
|
||||
if (!response || error) {
|
||||
alert(error?.message || 'Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
onClose('Thanks for your contribution! We will review it shortly.');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 mt-2 rounded-md border bg-gray-100 p-3">
|
||||
<h1 className="mb-2 text-2xl font-bold">Guidelines</h1>
|
||||
<ul class="flex flex-col gap-1 text-sm text-gray-700">
|
||||
<li>Content should only be in English.</li>
|
||||
<li>Do not add things you have not evaluated personally.</li>
|
||||
<li>It should strictly be relevant to the topic.</li>
|
||||
<li>It should not be paid or behind a signup.</li>
|
||||
<li>
|
||||
Quality over quantity. Smaller set of quality links is preferred.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit}>
|
||||
{links.map((link, counter) => (
|
||||
<ContributionInput
|
||||
key={link.id}
|
||||
id={link.id}
|
||||
title={link.title}
|
||||
link={link.link}
|
||||
isLast={counter === links.length - 1}
|
||||
totalCount={links.length}
|
||||
onChange={(newLink) => {
|
||||
setLinks(
|
||||
links.map((l) => {
|
||||
if (l.id === link.id) {
|
||||
return newLink;
|
||||
}
|
||||
|
||||
return l;
|
||||
})
|
||||
);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setLinks(links.filter((l) => l.id !== link.id));
|
||||
}}
|
||||
onAdd={() => {
|
||||
setLinks([
|
||||
...links,
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
title: '',
|
||||
link: '',
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white hover:bg-black disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
{isSubmitting ? 'Please wait ...' : 'Submit'}
|
||||
</button>
|
||||
<button
|
||||
className="block w-full rounded-md border border-red-500 p-2 text-sm text-red-600 hover:bg-red-600 hover:text-white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,16 +10,21 @@ import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
isTopicDone,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
ResourceType,
|
||||
updateResourceProgress as updateResourceProgressApi,
|
||||
} from '../../lib/resource-progress';
|
||||
import { pageLoadingMessage, sponsorHidden } from '../../stores/page';
|
||||
import { pageProgressMessage, sponsorHidden } from '../../stores/page';
|
||||
import { TopicProgressButton } from './TopicProgressButton';
|
||||
import { ContributionForm } from './ContributionForm';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
export function TopicDetail() {
|
||||
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isContributing, setIsContributing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
|
||||
@@ -31,28 +36,15 @@ export function TopicDetail() {
|
||||
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);
|
||||
setIsContributing(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
});
|
||||
|
||||
// Toggle topic is available even if the component UI is not active
|
||||
@@ -64,7 +56,7 @@ export function TopicDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
pageLoadingMessage.set('Updating');
|
||||
pageProgressMessage.set('Updating');
|
||||
|
||||
// Toggle the topic status
|
||||
isTopicDone({ topicId, resourceId, resourceType })
|
||||
@@ -83,13 +75,14 @@ export function TopicDetail() {
|
||||
topicId,
|
||||
done.includes(topicId) ? 'done' : 'pending'
|
||||
);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +92,7 @@ export function TopicDetail() {
|
||||
setIsActive(true);
|
||||
sponsorHidden.set(true);
|
||||
|
||||
setContributionAlertMessage('');
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
@@ -142,10 +136,6 @@ export function TopicDetail() {
|
||||
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
|
||||
@@ -162,7 +152,22 @@ export function TopicDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
{!isLoading && isContributing && (
|
||||
<ContributionForm
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
topicId={topicId}
|
||||
onClose={(message?: string) => {
|
||||
if (message) {
|
||||
setContributionAlertMessage(message);
|
||||
}
|
||||
|
||||
setIsContributing(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isContributing && !isLoading && !error && (
|
||||
<>
|
||||
{/* Actions for the topic */}
|
||||
<div className="mb-2">
|
||||
@@ -170,9 +175,9 @@ export function TopicDetail() {
|
||||
topicId={topicId}
|
||||
resourceId={resourceId}
|
||||
resourceType={resourceType}
|
||||
onShowLoginPopup={showLoginPopup}
|
||||
onClose={() => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -180,7 +185,10 @@ export function TopicDetail() {
|
||||
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)}
|
||||
onClick={() => {
|
||||
setIsActive(false);
|
||||
setIsContributing(false);
|
||||
}}
|
||||
>
|
||||
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
|
||||
</button>
|
||||
@@ -193,20 +201,29 @@ export function TopicDetail() {
|
||||
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}
|
||||
{/* Contribution */}
|
||||
<div className="mt-8 flex-1 border-t">
|
||||
<p class="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||
Help others learn by submitting links to learn more about this topic{' '}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isGuest) {
|
||||
setIsActive(false);
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsContributing(true);
|
||||
}}
|
||||
disabled={!!contributionAlertMessage}
|
||||
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||
>
|
||||
on GitHub repository.
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{contributionAlertMessage
|
||||
? contributionAlertMessage
|
||||
: 'Submit a Link'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
ResourceProgressType,
|
||||
ResourceType,
|
||||
getTopicStatus,
|
||||
refreshProgressCounters,
|
||||
renderTopicProgress,
|
||||
updateResourceProgress,
|
||||
} from '../../lib/resource-progress';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
|
||||
type TopicProgressButtonProps = {
|
||||
topicId: string;
|
||||
resourceId: string;
|
||||
resourceType: ResourceType;
|
||||
|
||||
onShowLoginPopup: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -29,7 +30,7 @@ const statusColors: Record<ResourceProgressType, string> = {
|
||||
};
|
||||
|
||||
export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const { topicId, resourceId, resourceType, onClose, onShowLoginPopup } =
|
||||
const { topicId, resourceId, resourceType, onClose } =
|
||||
props;
|
||||
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
@@ -118,7 +119,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
const handleUpdateResourceProgress = (progress: ResourceProgressType) => {
|
||||
if (isGuest) {
|
||||
onClose();
|
||||
onShowLoginPopup();
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -135,6 +136,7 @@ export function TopicProgressButton(props: TopicProgressButtonProps) {
|
||||
setProgress(progress);
|
||||
onClose();
|
||||
renderTopicProgress(topicId, progress);
|
||||
refreshProgressCounters();
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
|
||||
export default function UpdatePasswordForm() {
|
||||
const [authProvider, setAuthProvider] = useState('');
|
||||
@@ -72,15 +72,17 @@ export default function UpdatePasswordForm() {
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile().finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
pageProgressMessage.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">
|
||||
<div class="hidden md:block mb-8">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Password</h2>
|
||||
<p className="mt-2 text-gray-400">Use the form below to update your password.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{authProvider === 'email' && (
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
@@ -132,7 +134,7 @@ export default function UpdatePasswordForm() {
|
||||
for="new-password-confirmation"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
New Password Confirm
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -141,7 +143,7 @@ export default function UpdatePasswordForm() {
|
||||
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"
|
||||
placeholder="Confirm New Password"
|
||||
value={newPasswordConfirmation}
|
||||
onInput={(e) =>
|
||||
setNewPasswordConfirmation((e.target as HTMLInputElement).value)
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
import UploadProfilePicture from '../Profile/UploadProfilePicture';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import UploadProfilePicture from './UploadProfilePicture';
|
||||
|
||||
export function UpdateProfileForm() {
|
||||
const [name, setName] = useState('');
|
||||
@@ -75,14 +75,16 @@ export function UpdateProfileForm() {
|
||||
// Make a request to the backend to fill in the form with the current values
|
||||
useEffect(() => {
|
||||
loadProfile().finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
pageProgressMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
|
||||
<p className="mt-2">Update your profile details below.</p>
|
||||
<div className="mb-8 hidden md:block">
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
|
||||
<p className="mt-2 text-gray-400">Update your profile details below.</p>
|
||||
</div>
|
||||
<UploadProfilePicture
|
||||
avatarUrl={
|
||||
avatar
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpCall, httpPost } from '../../lib/http';
|
||||
|
||||
interface PreviewFile extends File {
|
||||
preview: string;
|
||||
@@ -131,7 +130,7 @@ export default function UploadProfilePicture(props: UploadProfilePictureProps) {
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
encType="multipart/form-data"
|
||||
className="mt-8 flex flex-col gap-2"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<label htmlFor="avatar" className="text-sm leading-none text-slate-500">
|
||||
Profile Picture
|
||||
@@ -7,6 +7,6 @@
|
||||
class="bg-red-600 group-hover:bg-red-800 group-hover: px-1.5 py-0.5 rounded-sm text-white text-xs uppercase font-medium mr-2"
|
||||
>New</span
|
||||
>
|
||||
<span class="underline mr-1">Roadmap topics to be covered on YouTube</span>
|
||||
<span class="underline mr-1">We also have a YouTube channel with visual content</span>
|
||||
<span>»</span>
|
||||
</a>
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/api-security.pdf'
|
||||
order: 2
|
||||
briefTitle: 'API Security'
|
||||
briefDescription: 'API Security Best Practices'
|
||||
isNew: true
|
||||
isNew: false
|
||||
isUpcoming: false
|
||||
title: 'API Security Best Practices'
|
||||
description: 'Detailed list of best practices to make your APIs secure'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Disable Entinty Parsing in XML
|
||||
# Disable Entity Parsing in XML
|
||||
|
||||
> Disable entity parsing if you are parsing XML to avoid XXE attacks
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/aws.pdf'
|
||||
order: 3
|
||||
briefTitle: 'AWS'
|
||||
briefDescription: 'AWS Best Practices'
|
||||
isNew: true
|
||||
isNew: false
|
||||
isUpcoming: false
|
||||
title: 'AWS Best Practices'
|
||||
description: 'Detailed list of best practices for Amazon Web Services (AWS)'
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
> Get your alerts to become notifications.
|
||||
|
||||
If you've set everyting up correctly, your health checks should automatically destroy bad instances and spawn new ones. There's usually no action to take when getting a CloudWatch alert, as everything should be automated. If you're getting alerts where manual intervention is required, do a post-mortem and figure out if there's a way you can automate the action in future. The last time I had an actionable alert from CloudWatch was about a year ago, and it's extremely awesome not to be woken up at 4am for ops alerts any more.
|
||||
If you've set everything up correctly, your health checks should automatically destroy bad instances and spawn new ones. There's usually no action to take when getting a CloudWatch alert, as everything should be automated. If you're getting alerts where manual intervention is required, do a post-mortem and figure out if there's a way you can automate the action in future. The last time I had an actionable alert from CloudWatch was about a year ago, and it's extremely awesome not to be woken up at 4am for ops alerts any more.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# App Changes for AWS
|
||||
|
||||
While a lot of applications can probably just be deployed to an EC2 instance and work well, if you're coming from a physical environment, you may need to re-architect your application in order to accomodate changes. Don't just think you can copy the files over and be done with it.
|
||||
While a lot of applications can probably just be deployed to an EC2 instance and work well, if you're coming from a physical environment, you may need to re-architect your application in order to accommodate changes. Don't just think you can copy the files over and be done with it.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
> Don't give servers static/elastic IPs.
|
||||
|
||||
For a typical web application, you should put things behind a load balancer, and balance them between AZs. There are a few cases where Elastic IPs will probably need to be used, but in order to make best use of auto-scaling you'll want to use a load balancer instad of giving every instance their own unique IP.
|
||||
For a typical web application, you should put things behind a load balancer, and balance them between AZs. There are a few cases where Elastic IPs will probably need to be used, but in order to make best use of auto-scaling you'll want to use a load balancer instead of giving every instance their own unique IP.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
> Set up granular billing alerts.
|
||||
|
||||
You should always have at least one billing alert set up, but that will only tell you on a monthly basis once you've exceeded your allowance. If you want to catch runaway billing early, you need a more fine grained approach. The way I do it is to set up an alert for my expected usage each week. So the first week's alert for say $1,000, the second for $2,000, third for $3,000, etc. If the week-2 alarm goes off before the 14th/15th of the month, then I know something is probably going wrong. For even more fine-grained control, you can set this up for each individual service, that way you instantly know which service is causing the problem. This could be useful if your usage on one service is quite steady month-to-month, but another is more erratic. Have the indidividual weekly alerts for the steady one, but just an overall one for the more erratic one. If everything is steady, then this is probably overkill, as looking at CloudWatch will quickly tell you which service is the one causing the problem.
|
||||
You should always have at least one billing alert set up, but that will only tell you on a monthly basis once you've exceeded your allowance. If you want to catch runaway billing early, you need a more fine grained approach. The way I do it is to set up an alert for my expected usage each week. So the first week's alert for say $1,000, the second for $2,000, third for $3,000, etc. If the week-2 alarm goes off before the 14th/15th of the month, then I know something is probably going wrong. For even more fine-grained control, you can set this up for each individual service, that way you instantly know which service is causing the problem. This could be useful if your usage on one service is quite steady month-to-month, but another is more erratic. Have the individual weekly alerts for the steady one, but just an overall one for the more erratic one. If everything is steady, then this is probably overkill, as looking at CloudWatch will quickly tell you which service is the one causing the problem.
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
> Scale down on INSUFFICIENT_DATA as well as ALARM.
|
||||
|
||||
For your scale-down action, make sure to trigger a scale-down event when there's no metric data, as well as when your trigger goes off. For example, if you have an app which usually has very low traffic, but experiences occasional spikes, you want to be sure that it scales down once the spike is over and the traffic stops. If there's no traffic, you'll get `INSUFFIFIENT_DATA` instead of `ALARM` for your low traffic threshold and it won't trigger a scale-down action.
|
||||
For your scale-down action, make sure to trigger a scale-down event when there's no metric data, as well as when your trigger goes off. For example, if you have an app which usually has very low traffic, but experiences occasional spikes, you want to be sure that it scales down once the spike is over and the traffic stops. If there's no traffic, you'll get `INSUFFICIENT_DATA` instead of `ALARM` for your low traffic threshold and it won't trigger a scale-down action.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Use Official SDKs
|
||||
|
||||
> If you need to interact with AWS, use the SDK for your langauge.
|
||||
> If you need to interact with AWS, use the SDK for your language.
|
||||
|
||||
Don't try to roll your own, I did this at first as I only needed a simple upload to S3, but then you add more services and it's just an all around bad idea. [The AWS SDKs](http://aws.amazon.com/tools/) are well written, handle authentication automatically, handle retry logic, and they're maintained and iterated on by Amazon. Also, if you use EC2 IAM roles (which you absolutely should, more on this later) then the SDK will automatically grab the correct credentials for you.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Use VPC
|
||||
|
||||
Setting up a VPC seems like a pain at first, but once you get stuck in and play with it, it's suprising easy to set up and get going. It provides all sorts of extra features over EC2 that are well worth the extra time it takes to set up a VPC. First, you can control traffic at the network level using ACLs, you can modify instance size, security groups, etc. without needing to terminate an instance. You can specify egress firewall rules (you cannot control outbound traffic from normal EC2). But the biggest thing is that you have your own private subnet where your instances are completely cut off from everyone else, so it adds an extra layer of protection.
|
||||
Setting up a VPC seems like a pain at first, but once you get stuck in and play with it, it's surprising easy to set up and get going. It provides all sorts of extra features over EC2 that are well worth the extra time it takes to set up a VPC. First, you can control traffic at the network level using ACLs, you can modify instance size, security groups, etc. without needing to terminate an instance. You can specify egress firewall rules (you cannot control outbound traffic from normal EC2). But the biggest thing is that you have your own private subnet where your instances are completely cut off from everyone else, so it adds an extra layer of protection.
|
||||
|
||||
If you're interested in the internals of VPC, I highly recommend watching [A Day in the Life of Billion Packets](http://www.youtube.com/watch?v=Zd5hsL-JNY4) ([Slides](https://www.slideshare.net/AmazonWebServices/a-day-in-the-life-of-a-billion-packets-cpn401-aws-reinvent-2013)).
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/code-review.pdf'
|
||||
order: 2
|
||||
briefTitle: 'Code Reviews'
|
||||
briefDescription: 'Code Review Best Practices'
|
||||
isNew: true
|
||||
isNew: false
|
||||
isUpcoming: false
|
||||
title: 'Code Review Best Practices'
|
||||
description: 'Detailed list of best practices for effective code reviews and quality'
|
||||
|
||||
@@ -7,6 +7,6 @@ When CSS files are minified, the content is loaded faster and less data is sent
|
||||
Use tools to minify your files automatically before or during your build or your deployment.
|
||||
|
||||
- [cssnano: A modular minifier based on the PostCSS ecosystem. - cssnano](https://cssnano.co/)
|
||||
- [CSS Minfier](https://goonlinetools.com/css-minifier/)
|
||||
- [CSS Minifier](https://goonlinetools.com/css-minifier/)
|
||||
- [@neutrinojs/style-minify - npm](https://www.npmjs.com/package/@neutrinojs/style-minify)
|
||||
- [Online CSS Compressor](http://refresh-sf.com)
|
||||
|
||||
@@ -4,7 +4,7 @@ pdfUrl: '/pdfs/best-practices/frontend-performance.pdf'
|
||||
order: 1
|
||||
briefTitle: 'Frontend Performance'
|
||||
briefDescription: 'Frontend Performance Best Practices'
|
||||
isNew: true
|
||||
isNew: false
|
||||
isUpcoming: false
|
||||
title: 'Frontend Performance Best Practices'
|
||||
description: 'Detailed list of best practices to improve your frontend performance'
|
||||
|
||||
@@ -8,7 +8,7 @@ author:
|
||||
seo:
|
||||
title: 'Consistency Patterns - roadmap.sh'
|
||||
description: 'Everything you need to know about Week, Strong and Eventual Consistency'
|
||||
isNew: true
|
||||
isNew: false
|
||||
canonicalUrl: 'https://cs.fyi/guide/consistency-patterns-week-strong-eventual/'
|
||||
type: 'textual'
|
||||
date: 2023-01-18
|
||||
@@ -61,7 +61,7 @@ An example of strong consistency is a financial system where users can transfer
|
||||
|
||||
In a weakly consistent system, updates to the data may not be immediately propagated. This can lead to inconsistencies and conflicts between different versions of the data, but it also allows for **high availability and low latency**.
|
||||
|
||||
Another example of weak consistency is a gaming platform where users can play online multiplayer games. When a user plays a game, their actions are immediately visible to other players in the same data center, but if there was a lag or temporary connectoin loss, the actions may not be seen by some of the users and the game will continue. This can lead to inconsistencies between different versions of the game state, but it also allows for a high level of availability and low latency.
|
||||
Another example of weak consistency is a gaming platform where users can play online multiplayer games. When a user plays a game, their actions are immediately visible to other players in the same data center, but if there was a lag or temporary connection loss, the actions may not be seen by some of the users and the game will continue. This can lead to inconsistencies between different versions of the game state, but it also allows for a high level of availability and low latency.
|
||||
|
||||
### Eventual Consistency
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ author:
|
||||
seo:
|
||||
title: 'Jump Servers: What, Why and How - roadmap.sh'
|
||||
description: 'Learn what is a Jump Server and how to set it up for SSH access.'
|
||||
isNew: true
|
||||
isNew: false
|
||||
type: 'textual'
|
||||
date: 2023-03-20
|
||||
sitemap:
|
||||
|
||||
@@ -8,7 +8,7 @@ author:
|
||||
seo:
|
||||
title: "Guide to Let's Encrypt SSL Setup - roadmap.sh"
|
||||
description: "Learn how to protect your website using Let's Encrypt SSL Certificates."
|
||||
isNew: true
|
||||
isNew: false
|
||||
type: 'textual'
|
||||
date: 2023-03-13
|
||||
sitemap:
|
||||
|
||||
@@ -8,7 +8,7 @@ author:
|
||||
seo:
|
||||
title: 'Single Command Database Setup - roadmap.sh'
|
||||
description: 'Learn how to run MySQL, PostgreSQL, or MongoDB in Docker with single Command'
|
||||
isNew: true
|
||||
isNew: false
|
||||
type: 'textual'
|
||||
date: 2023-02-27
|
||||
sitemap:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
byte-byte-go: https://blog.bytebytego.com/archive
|
||||
speedup-js: https://marvinh.dev/blog/speeding-up-javascript-ecosystem/
|
||||
23-min-ts: https://www.youtube.com/watch?v=YmxwicpROps
|
||||
bun-vs-node: https://www.youtube.com/watch?v=qCX8rw4qOSA
|
||||
|
||||
@@ -6,3 +6,4 @@ Visit the following resources to learn more:
|
||||
|
||||
- [Union Types - typescriptlang](https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html)
|
||||
- [Union Type video for Beginners](https://www.youtube.com/watch?v=uxjpm4W5pCo)
|
||||
- [Union Types - typescriptlang](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types)
|
||||
@@ -9,3 +9,4 @@ Type guards are typically used for narrowing a type and are pretty similar to fe
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Types Guards - Blog](https://blog.logrocket.com/how-to-use-type-guards-typescript/)
|
||||
- [TypeScript Type Guards Explained](https://www.youtube.com/watch?v=feeeitmtdwg)
|
||||
@@ -7,4 +7,4 @@ Use pipes to transform strings, currency amounts, dates, and other data for disp
|
||||
Visit the following resources to learn more:
|
||||
|
||||
- [Understanding BuiltIn Pipes](https://angular.io/guide/pipes)
|
||||
- [BuiltIn Pipes - exampls](https://codecraft.tv/courses/angular/pipes/built-in-pipes/)
|
||||
- [BuiltIn Pipes - examples](https://codecraft.tv/courses/angular/pipes/built-in-pipes/)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user