Compare commits

...

146 Commits

Author SHA1 Message Date
Kamran Ahmed
912300f15f Disable github login for now 2023-04-14 20:16:00 +01:00
Kamran Ahmed
4e2deb13bd Fix UI issue 2023-04-14 19:43:20 +01:00
Kamran Ahmed
b04e105ff7 Minor improvements 2023-04-14 19:15:42 +01:00
Kamran Ahmed
c13ca84190 Update fingerprint 2023-04-14 17:56:35 +01:00
Kamran Ahmed
a544cfea8d Use http wrapper instead of fetch 2023-04-14 00:24:49 +01:00
Kamran Ahmed
0da417735e Add fingerprint to user requests 2023-04-13 16:32:12 +01:00
Kamran Ahmed
7dbb00a306 Add spinner on setting pages 2023-04-13 02:09:45 +01:00
Kamran Ahmed
ae72680a5b Add page wide spinner 2023-04-13 01:30:36 +01:00
Kamran Ahmed
3825968106 Add progress loader 2023-04-12 23:05:27 +01:00
Kamran Ahmed
20db0baec5 Update topic done functionality 2023-04-12 19:04:59 +01:00
Kamran Ahmed
78e4c38c97 Add user progress tracking 2023-04-12 18:11:56 +01:00
Kamran Ahmed
f83cd701e9 Update public api url 2023-04-11 13:30:38 +01:00
Kamran Ahmed
f17673b6e7 Keep track of the old page before social login 2023-04-11 13:23:49 +01:00
Arik Chakma
a730c81886 fix: query selector for topics 2023-04-11 13:33:54 +06:00
Arik Chakma
a9d70f665b chore: fetch progress 2023-04-11 13:28:02 +06:00
Arik Chakma
2c06225c3c fix: best practice topic toggle 2023-04-11 12:41:00 +06:00
Arik Chakma
0018627afe chore: get user resource progress api 2023-04-11 12:28:06 +06:00
Arik Chakma
7177638b49 chore: toggle topic done 2023-04-11 12:19:46 +06:00
Arik Chakma
38b1cf4b33 chore: toggle mark resource done api 2023-04-11 12:08:07 +06:00
Kamran Ahmed
a0764aa7be Update design 2023-04-10 23:28:08 +01:00
Kamran Ahmed
f1fc4ef2bc Update profile page 2023-04-10 23:25:01 +01:00
Kamran Ahmed
a4256db687 Update password page 2023-04-10 23:19:38 +01:00
Kamran Ahmed
33d7257ed2 Add update password form 2023-04-10 23:09:44 +01:00
Kamran Ahmed
e849eeeca5 Update profile page 2023-04-10 22:30:42 +01:00
Kamran Ahmed
44ce3a3c98 Change placement of constant 2023-04-10 19:35:38 +01:00
Kamran Ahmed
3072e42b0a Add reset password page 2023-04-10 19:04:16 +01:00
Kamran Ahmed
725aa2b092 Remember page when logging in 2023-04-10 18:19:21 +01:00
Kamran Ahmed
9bcf421590 Popup opener to close the overlay 2023-04-10 18:12:42 +01:00
Kamran Ahmed
91ce20776c Add route protection 2023-04-10 16:42:20 +01:00
Kamran Ahmed
dbe99df82c Handle logout 2023-04-10 16:34:15 +01:00
Kamran Ahmed
7a2e283281 Refactor logic for mark done and pending 2023-04-10 16:22:25 +01:00
Kamran Ahmed
a5866608e5 Add authentication popup 2023-04-10 16:13:20 +01:00
Kamran Ahmed
cdc7f081a3 Rename fetch lib 2023-04-10 15:26:24 +01:00
Kamran Ahmed
8b285cc600 Add forgot password 2023-04-10 14:13:51 +01:00
Kamran Ahmed
82334bbcae Refactor logic for download and subscribe popups 2023-04-09 20:00:44 +01:00
Kamran Ahmed
ec45a565ea Add ease-in on the guest elements 2023-04-09 19:51:33 +01:00
Kamran Ahmed
aece5a4eea Show hide auth elements change 2023-04-09 19:41:39 +01:00
Kamran Ahmed
332c71c16e Handle authenticatoin 2023-04-09 19:07:49 +01:00
Kamran Ahmed
0389cd8f51 Email login form 2023-04-09 18:46:04 +01:00
Kamran Ahmed
768e7ab67d Add login button in top nav 2023-04-09 18:43:52 +01:00
Kamran Ahmed
3cd8468c8f Add login page 2023-04-09 17:48:06 +01:00
Kamran Ahmed
1950404b20 Update signup text 2023-04-09 17:17:39 +01:00
Kamran Ahmed
81be1d8802 Add verify account functionality 2023-04-09 17:09:44 +01:00
Kamran Ahmed
d29aeaf000 Refactor verification pending page 2023-04-09 16:01:27 +01:00
Kamran Ahmed
3dfaad8704 Resend verfication email functionality 2023-04-09 15:59:13 +01:00
Kamran Ahmed
1ea66824d0 Refactor email sign up form 2023-04-09 14:52:10 +01:00
Kamran Ahmed
9c8cd71ed2 Remove captcha and add google scripts 2023-04-09 14:18:11 +01:00
Kamran Ahmed
6b4be3f0ab Refactor login button 2023-04-09 05:56:07 +01:00
Kamran Ahmed
983476eb37 Refactor top navigation 2023-04-08 14:21:39 +01:00
Kamran Ahmed
22174954ab Update dependencies 2023-04-08 14:00:55 +01:00
Kamran Ahmed
f71613eec0 Resolve merge conflcits 2023-04-08 14:00:17 +01:00
Kamran Ahmed
1a2d58689e Prettier 2023-04-08 13:59:33 +01:00
Kamran Ahmed
6a0403ec25 Refactor top navigation 2023-04-08 13:56:18 +01:00
Kamran Ahmed
bf9e767083 Formatting 2023-04-07 16:02:00 +01:00
Arik Chakma
54ac3ee2cf fix: div tag missing 2023-04-05 23:52:15 +06:00
Arik Chakma
50ae6f9cda chore: email verify page 2023-04-05 23:48:59 +06:00
Arik Chakma
36aaf0dfaf chore: base verify design 2023-04-05 21:46:38 +06:00
Arik Chakma
52219d4b27 fix: topic overlay hide 2023-04-05 01:02:57 +06:00
Arik Chakma
ee55b0192b fix: verify account text 2023-04-04 00:47:25 +06:00
Arik Chakma
729751804b chore: best practices buttons 2023-04-04 00:18:48 +06:00
Arik Chakma
46a6d8c0c5 chore: roadmap pdf link download 2023-04-04 00:08:43 +06:00
Arik Chakma
ced7cf8135 chore: roadmap pdf link download 2023-04-04 00:05:01 +06:00
Arik Chakma
b72c5b203b chore: chevron down account 2023-04-04 00:00:45 +06:00
Arik Chakma
0fc72f2dcf style: resend email underline 2023-04-03 23:50:40 +06:00
Arik Chakma
765efd4eb6 chore: keep the spinner 2023-04-03 23:45:54 +06:00
Arik Chakma
21be8e526f chore: keep spinner after success to redirect 2023-04-03 23:41:34 +06:00
Arik Chakma
f82c637bd2 fix: remove spinner 2023-04-03 23:41:23 +06:00
Arik Chakma
05c9bbec8b chore: loading spinner accessibilities 2023-04-03 08:16:17 +06:00
Arik Chakma
11d7618230 fix: 401 error code redirect to login page 2023-04-03 03:44:04 +06:00
Arik Chakma
9ca74a5750 chore: error messages 2023-04-03 03:34:51 +06:00
Arik Chakma
9d8889a492 fix: de-structure error 2023-04-03 03:13:19 +06:00
Arik Chakma
0fa4533168 fix: uncontrolled to controlled form 2023-04-02 23:47:42 +06:00
Arik Chakma
931dac64ff chore: mark as done overlay 2023-04-02 23:08:12 +06:00
Arik Chakma
423df1a225 refactor: update profile errors 2023-04-02 22:30:54 +06:00
Arik Chakma
61fe3a16bc refactor: change password errors 2023-04-02 22:29:17 +06:00
Arik Chakma
7dc2d9169b chore: internal paths 2023-04-02 22:25:09 +06:00
Arik Chakma
580ee114c9 chore: internal pages guard 2023-04-02 22:14:11 +06:00
Arik Chakma
f8c4023d9a chore: change password for social provider 2023-04-02 22:09:32 +06:00
Arik Chakma
277a3aca06 chore: forgot password link 2023-04-02 21:41:18 +06:00
Arik Chakma
fa9f87b310 fix: replaced constants 2023-04-02 21:25:44 +06:00
Arik Chakma
173e37af38 chore: forgot password link add 2023-04-02 21:17:04 +06:00
Arik Chakma
e41cc2f4e6 chore: dummy placeholder 2023-04-02 21:00:09 +06:00
Arik Chakma
6061377f70 chore: pre-fill user data 2023-04-02 20:33:10 +06:00
Arik Chakma
1c55d2f91c chore: astro spinner 2023-04-02 11:59:34 +06:00
Arik Chakma
bb9a6431df refactor: email login form 2023-04-02 11:41:50 +06:00
Arik Chakma
024940cf5c fix: spacing for login and signup page 2023-04-02 03:36:55 +06:00
Arik Chakma
094d350855 chore: login page 2023-04-02 02:10:30 +06:00
Arik Chakma
6ac01491cb chore: reset password functionality 2023-04-02 02:01:17 +06:00
Arik Chakma
4ef6eb0e20 chore: reset password page 2023-04-02 01:40:31 +06:00
Arik Chakma
3a589f94a7 fix: class -> className 2023-04-02 01:24:40 +06:00
Arik Chakma
9e71ce5cef chore: forgot password functionality 2023-04-02 01:24:10 +06:00
Arik Chakma
ac94b85c51 fix: types in spinner 2023-04-02 00:31:59 +06:00
Arik Chakma
97ad05e373 chore: resend verification email 2023-04-02 00:08:52 +06:00
Arik Chakma
41d6b51089 chore: verify account page 2023-04-01 21:57:30 +06:00
Arik Chakma
055d6e0023 chore: reset password page 2023-04-01 20:27:02 +06:00
Arik Chakma
47288bfc0e chore: forgot password page 2023-04-01 20:17:06 +06:00
Arik Chakma
c7fd995a86 chore: email login and signup components 2023-04-01 19:38:44 +06:00
Arik Chakma
6bf02c5687 fix: form data empty error 2023-04-01 19:18:16 +06:00
Arik Chakma
448c5cda5f chore: mobile navigation 2023-04-01 18:05:01 +06:00
Arik Chakma
ecb9de8a40 chore: update profile form 2023-04-01 17:39:22 +06:00
Arik Chakma
9a145cb785 chore: change password form 2023-04-01 17:11:15 +06:00
Arik Chakma
f5658e1980 chore: required indicator 2023-04-01 06:57:04 +06:00
Arik Chakma
fc054e20e7 fix: setting dropdown design 2023-04-01 06:53:17 +06:00
Arik Chakma
884bd5d66e chore: setting sidebar 2023-04-01 06:30:21 +06:00
Arik Chakma
e40be008ba chore: update profile page 2023-04-01 04:32:12 +06:00
Arik Chakma
2c415a30b0 fix: change password rename 2023-04-01 04:22:13 +06:00
Arik Chakma
b69ec5617d chore: rename profile to password 2023-04-01 04:21:39 +06:00
Arik Chakma
ca7b9d9744 chore: change password page 2023-04-01 04:20:15 +06:00
Arik Chakma
6964c06d91 chore: github login error handling 2023-04-01 01:03:30 +06:00
Arik Chakma
854779fcc8 chore: google login error handling 2023-04-01 00:27:34 +06:00
Arik Chakma
e1f6ac68f9 chore: github astro component 2023-03-31 23:09:10 +06:00
Arik Chakma
8d923c4135 chore: preact to astro comp 2023-03-31 20:55:01 +06:00
Arik Chakma
754ea69bc6 chore: preact to astro components 2023-03-31 19:58:01 +06:00
Arik Chakma
edd93b1d1d fix: button size 2023-03-31 18:15:29 +06:00
Arik Chakma
1c0eee6929 chore: profile guard clause 2023-03-31 18:04:00 +06:00
Arik Chakma
0185bf179a chore: google login implementation 2023-03-31 17:58:56 +06:00
Kamran Ahmed
df44dad61f Add login with google 2023-03-31 06:31:31 +01:00
Kamran Ahmed
c5cda201ef Remove unused styles 2023-03-31 05:55:02 +01:00
Kamran Ahmed
f1de53c191 Add missing content for backend roadmap 2023-03-31 05:55:02 +01:00
Arik Chakma
eaec8d70f7 chore: profile page 2023-03-31 05:21:25 +06:00
Arik Chakma
5e690b854c fix: dropdown z index 2023-03-31 01:33:08 +06:00
Arik Chakma
cbb322b66f chore: account dropdown 2023-03-31 01:31:03 +06:00
Arik Chakma
da9c897765 chore: download button link 2023-03-31 00:57:19 +06:00
Arik Chakma
4c525f8dc0 chore: logout vs login 2023-03-31 00:34:05 +06:00
Arik Chakma
00133369af chore: use auth hook 2023-03-31 00:13:45 +06:00
Arik Chakma
5cdc443261 chore: added name in token decode return 2023-03-30 21:52:35 +06:00
Arik Chakma
f354130f35 chore: login error message 2023-03-30 20:10:51 +06:00
Arik Chakma
b2934993b0 chore: login feature 2023-03-30 20:00:09 +06:00
Arik Chakma
b5d47349a1 Merge branch 'master' into feat/login-design 2023-03-30 06:48:17 +06:00
Arik Chakma
8eadfb6640 Merge branch 'feat/preact-migrate' of https://github.com/arikchakma/developer-roadmap into feat/login-design 2023-03-30 06:29:49 +06:00
Arik Chakma
407d70d462 chore: auth divider 2023-03-30 06:27:39 +06:00
Arik Chakma
d338480802 chore: signup page design 2023-03-30 06:27:39 +06:00
Arik Chakma
a4efe04a61 chore: login popup design 2023-03-30 06:27:39 +06:00
Arik Chakma
0688968c22 chore: signup page 2023-03-30 06:27:39 +06:00
Arik Chakma
1d9cb45733 refactor: github and google button 2023-03-30 06:27:39 +06:00
Arik Chakma
351e1e4509 chore: data-popup changed 2023-03-30 06:27:39 +06:00
Arik Chakma
410308bf7f chore: login popup design 2023-03-30 06:27:39 +06:00
Arik Chakma
e6054e247c feat: integrate astro 2023-03-30 06:27:39 +06:00
Arik Chakma
174803dc2a chore: auth divider 2023-03-30 04:37:39 +06:00
Arik Chakma
77de8fdd3d chore: signup page design 2023-03-30 00:39:08 +06:00
Arik Chakma
678363f77d chore: login popup design 2023-03-30 00:33:55 +06:00
Arik Chakma
b424404bfd chore: signup page 2023-03-29 22:40:59 +06:00
Arik Chakma
8102f60ebc refactor: github and google button 2023-03-29 22:24:11 +06:00
Arik Chakma
130d460b94 chore: data-popup changed 2023-03-29 22:08:56 +06:00
Arik Chakma
52d110dffe chore: login popup design 2023-03-29 21:48:46 +06:00
Arik Chakma
5bd3d90d3c feat: integrate astro 2023-03-29 20:30:18 +06:00
82 changed files with 3746 additions and 1191 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
PUBLIC_API_URL=http://api.roadmap.sh

View File

@@ -3,6 +3,7 @@ on:
push:
branches: [ master ]
env:
PUBLIC_API_URL: "https://api.roadmap.sh"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PAT: ${{ secrets.PAT }}
CI: true

View File

@@ -1,11 +1,13 @@
// https://astro.build/config
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import compress from 'astro-compress';
import { defineConfig } from 'astro/config';
import compress from 'astro-compress';
import rehypeExternalLinks from 'rehype-external-links';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
import preact from '@astrojs/preact';
// https://astro.build/config
export default defineConfig({
site: 'https://roadmap.sh/',
markdown: {
@@ -56,5 +58,6 @@ export default defineConfig({
css: false,
js: false,
}),
preact(),
],
});

View File

@@ -20,25 +20,33 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/sitemap": "^1.2.1",
"@astrojs/preact": "^2.1.0",
"@astrojs/sitemap": "^1.2.2",
"@astrojs/tailwind": "^3.1.1",
"astro": "^2.1.9",
"@fingerprintjs/fingerprintjs": "^3.4.1",
"@nanostores/preact": "^0.3.1",
"astro": "^2.2.3",
"astro-compress": "^1.1.35",
"jose": "^4.13.2",
"js-cookie": "^3.0.1",
"nanostores": "^0.7.4",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.9.0",
"npm-check-updates": "^16.10.8",
"preact": "^10.13.2",
"rehype-external-links": "^2.0.1",
"roadmap-renderer": "^1.0.4",
"roadmap-renderer": "^1.0.5",
"tailwindcss": "^3.3.1"
},
"devDependencies": {
"@playwright/test": "^1.32.1",
"@playwright/test": "^1.32.3",
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"gh-pages": "^5.0.0",
"js-yaml": "^4.1.0",
"markdown-it": "^13.0.1",
"openai": "^3.2.1",
"prettier": "^2.8.7",
"prettier-plugin-astro": "^0.8.0",
"prettier-plugin-tailwindcss": "^0.2.6"
"prettier-plugin-tailwindcss": "^0.2.7"
}
}

1615
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,18 @@
---
---
<script src='./analytics.js'></script>
<script src='./analytics.ts'></script>
<script async src='https://www.googletagmanager.com/gtag/js?id=UA-139582634-1'
></script>
<script is:inline>
// @ts-nocheck
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('js', new Date());
gtag('config', 'UA-139582634-1');
document.addEventListener('click', (e) => {

View File

@@ -35,5 +35,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
const svgAttributes = { ...baseAttributes, ...attributes };
---
<svg {...svgAttributes} set:html={innerHTML}></svg>
<svg {...svgAttributes} set:html={innerHTML}></svg>

View File

@@ -0,0 +1,5 @@
<div class='flex w-full items-center gap-2 py-6 text-sm text-slate-600'>
<div class='h-px w-full bg-slate-200'></div>
OR
<div class='h-px w-full bg-slate-200'></div>
</div>

View File

@@ -0,0 +1,100 @@
import Cookies from 'js-cookie';
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
const EmailLoginForm: FunctionComponent<{}> = () => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleFormSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-login`,
{
email,
password,
}
);
// Log the user in and reload the page
if (response?.token) {
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.reload();
return;
}
// @todo use proper types
if ((error as any).type === 'user_not_verified') {
window.location.href = `/verification-pending?email=${encodeURIComponent(
email
)}`;
return;
}
setIsLoading(false);
setError(error?.message || 'Something went wrong. Please try again later.');
};
return (
<form className="w-full" onSubmit={handleFormSubmit}>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Email Address"
value={email}
onInput={(e) => setEmail(String((e.target as any).value))}
/>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
name="password"
type="password"
autoComplete="current-password"
required
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Password"
value={password}
onInput={(e) => setPassword(String((e.target as any).value))}
/>
<p class="mb-3 mt-2 text-sm text-gray-500">
<a
href="/forgot-password"
className="text-blue-800 hover:text-blue-600"
>
Reset your password?
</a>
</p>
{error && (
<p className="mb-2 rounded-md bg-red-100 p-2 text-red-800">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue'}
</button>
</form>
);
};
export default EmailLoginForm;

View File

@@ -0,0 +1,103 @@
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
const EmailSignupForm: FunctionComponent = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-register`,
{
email,
password,
name,
}
);
if (error || response?.status !== 'ok') {
setIsLoading(false);
setError(
error?.message || 'Something went wrong. Please try again later.'
);
return;
}
window.location.href = `/verification-pending?email=${encodeURIComponent(
email
)}`;
};
return (
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
<label htmlFor="name" className="sr-only">
Name
</label>
<input
name="name"
type="text"
autoComplete="name"
min={3}
max={50}
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Full Name"
value={name}
onInput={(e) => setName(String((e.target as any).value))}
/>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
name="email"
type="email"
autoComplete="email"
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Email Address"
value={email}
onInput={(e) => setEmail(String((e.target as any).value))}
/>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
name="password"
type="password"
autoComplete="current-password"
min={6}
max={50}
required
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Password"
value={password}
onInput={(e) => setPassword(String((e.target as any).value))}
/>
{error && (
<p className="rounded-lg bg-red-100 p-2 text-red-700">{error}.</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}
</button>
</form>
);
};
export default EmailSignupForm;

View File

@@ -0,0 +1,64 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function ForgotPasswordForm() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-forgot-password`,
{
email,
}
);
setIsLoading(false);
if (error) {
setError(error.message);
} else {
setEmail('');
setSuccess('Check your email for a link to reset your password.');
}
};
return (
<form onSubmit={handleSubmit} class="w-full">
<input
type="email"
name="email"
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="Email Address"
value={email}
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
/>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-sm text-red-700">
{error}
</p>
)}
{success && (
<p className="mt-2 rounded-lg bg-green-100 p-2 text-sm text-green-700">
{success}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue'}
</button>
</form>
);
}

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'preact/hooks';
import GitHubIcon from '../../icons/github.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
type GitHubButtonProps = {};
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
const GITHUB_LAST_PAGE = 'githubLastPage';
export function GitHubButton(props: GitHubButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : GitHubIcon;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const provider = urlParams.get('provider');
if (!code || !state || provider !== 'github') {
return;
}
setIsLoading(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
window.location.search
}`
)
.then(({ response, error }) => {
if (!response?.token) {
const errMessage = error?.message || 'Something went wrong.';
setError(errMessage);
setIsLoading(false);
return;
}
let redirectUrl = '/';
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT);
const lastPageBeforeGithub = localStorage.getItem(GITHUB_LAST_PAGE);
// If the social redirect is there and less than 30 seconds old
// redirect to the page that user was on before they clicked the github login button
if (gitHubRedirectAt && lastPageBeforeGithub) {
const socialRedirectAtTime = parseInt(gitHubRedirectAt, 10);
const now = Date.now();
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeGithub;
}
}
localStorage.removeItem(GITHUB_REDIRECT_AT);
localStorage.removeItem(GITHUB_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
}, []);
const handleClick = async () => {
setIsLoading(true);
const { response, error } = await httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
);
if (error || !response?.loginUrl) {
setError(
error?.message || 'Something went wrong. Please try again later.'
);
setIsLoading(false);
return;
}
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
}
window.location.href = response.loginUrl;
};
return null;
return (
<>
<button
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading}
onClick={handleClick}
>
<img
src={icon}
alt="GitHub"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with GitHub
</button>
{error && (
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
)}
</>
);
}

View File

@@ -0,0 +1,116 @@
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import GoogleIcon from '../../icons/google.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet } from '../../lib/http';
type GoogleButtonProps = {};
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
const GOOGLE_LAST_PAGE = 'googleLastPage';
export function GoogleButton(props: GoogleButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const icon = isLoading ? SpinnerIcon : GoogleIcon;
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const provider = urlParams.get('provider');
if (!code || !state || provider !== 'google') {
return;
}
setIsLoading(true);
httpGet<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
window.location.search
}`
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
let redirectUrl = '/';
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT);
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_LAST_PAGE);
// If the social redirect is there and less than 30 seconds old
// redirect to the page that user was on before they clicked the github login button
if (googleRedirectAt && lastPageBeforeGoogle) {
const socialRedirectAtTime = parseInt(googleRedirectAt, 10);
const now = Date.now();
const timeSinceRedirect = now - socialRedirectAtTime;
if (timeSinceRedirect < 30 * 1000) {
redirectUrl = lastPageBeforeGoogle;
}
}
localStorage.removeItem(GOOGLE_REDIRECT_AT);
localStorage.removeItem(GOOGLE_LAST_PAGE);
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.href = redirectUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
}, []);
const handleClick = () => {
setIsLoading(true);
httpGet<{ loginUrl: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
)
.then(({ response, error }) => {
if (!response?.loginUrl) {
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
// For non authentication pages, we want to redirect back to the page
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
}
window.location.href = response.loginUrl;
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
};
return (
<>
<button
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoading}
onClick={handleClick}
>
<img
src={icon}
alt="Google"
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with Google
</button>
{error && (
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
)}
</>
);
}

View File

@@ -0,0 +1,32 @@
---
import Popup from '../Popup/Popup.astro';
import EmailLoginForm from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
---
<Popup id='login-popup' title='' subtitle=''>
<div class='text-center'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
Login to your account
</h2>
<p class='mt-2 text-sm leading-4 text-slate-600'>
You must be logged in to perform this action.
</p>
</div>
<div class='mt-7 flex flex-col gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
</div>
<Divider />
<EmailLoginForm client:load />
<div class='mt-6 text-center text-sm text-slate-600'>
Don't have an account?{' '}
<a href='/signup' class='font-medium text-[#4285f4]'> Sign up</a>
</div>
</Popup>

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
export default function ResetPasswordForm() {
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (!code) {
window.location.href = '/login';
} else {
setCode(code);
}
}, []);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
if (password !== passwordConfirm) {
setIsLoading(false);
setError('Passwords do not match.');
return;
}
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-reset-forgotten-password`,
{
newPassword: password,
confirmPassword: passwordConfirm,
code,
}
);
if (error?.message) {
setIsLoading(false);
setError(error.message);
return;
}
if (!response?.token) {
setIsLoading(false);
setError('Something went wrong. Please try again later.');
return;
}
const token = response.token;
Cookies.set(TOKEN_COOKIE_NAME, token);
window.location.href = '/';
};
return (
<form className="mx-auto w-full" onSubmit={handleSubmit}>
<input
type="password"
className="mb-2 mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="New Password"
value={password}
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
/>
<input
type="password"
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="Confirm New Password"
value={passwordConfirm}
onInput={(e) =>
setPasswordConfirm((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
<button
type="submit"
disabled={isLoading}
className="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Reset Password'}
</button>
</form>
);
}

View File

@@ -0,0 +1,79 @@
import SpinnerIcon from '../../icons/spinner.svg';
import ErrorIcon from '../../icons/error.svg';
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
export function TriggerVerifyAccount() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const triggerVerify = (code: string) => {
setIsLoading(true);
httpPost<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
{
code,
}
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong. Please try again.');
setIsLoading(false);
return;
}
Cookies.set(TOKEN_COOKIE_NAME, response.token);
window.location.href = '/';
})
.catch((err) => {
setIsLoading(false);
setError('Something went wrong. Please try again.');
});
};
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code')!;
if (!code) {
setIsLoading(false);
setError('Something went wrong. Please try again later.');
return;
}
triggerVerify(code);
}, []);
return (
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
<div className="mx-auto max-w-md text-center">
{isLoading && (
<img
alt={'Please wait.'}
src={SpinnerIcon}
class={'mx-auto h-16 w-16 animate-spin'}
/>
)}
{error && (
<img
alt={'Please wait.'}
src={ErrorIcon}
className={'mx-auto h-16 w-16'}
/>
)}
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
Verifying your account
</h2>
<div className="text-sm sm:text-base">
{isLoading && <p>Please wait while we verify your account..</p>}
{error && <p class="text-red-700">{error}</p>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import VerifyLetterIcon from '../../icons/verify-letter.svg';
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function VerificationEmailMessage() {
const [email, setEmail] = useState('..');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isEmailResent, setIsEmailResent] = useState(false);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setEmail(urlParams.get('email')!);
}, []);
const resendVerificationEmail = () => {
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-send-verification-email`, {
email,
})
.then(({ response, error }) => {
if (error) {
setIsEmailResent(false);
setError(error?.message || 'Something went wrong.');
setIsLoading(false);
return;
}
setIsEmailResent(true);
})
.catch(() => {
setIsEmailResent(false);
setIsLoading(false);
setError('Something went wrong. Please try again later.');
});
};
return (
<div className="mx-auto max-w-md text-center">
<img
alt="Verify Email"
src={VerifyLetterIcon}
class="mx-auto mb-4 h-20 w-40 sm:h-40"
/>
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
Verify your email address
</h2>
<div class="text-sm sm:text-base">
<p>
We have sent you an email at{' '}
<span className="font-bold">{email}</span>. Please click the link to
verify your account. This link will expire shortly, so please verify
soon!
</p>
<hr class="my-4" />
{!isEmailResent && (
<>
{isLoading && <p className="text-gray-400">Sending the email ..</p>}
{!isLoading && !error && (
<p>
Please make sure to check your spam folder. If you still don't
have the email click to{' '}
<button
disabled={!email}
className="inline text-blue-700"
onClick={resendVerificationEmail}
>
resend verification email.
</button>
</p>
)}
{error && <p class="text-red-700">{error}</p>}
</>
)}
{isEmailResent && (
<p class="text-green-700">Verification email has been sent!</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
---
---
<script src='./authenticator.ts'></script>

View File

@@ -0,0 +1,79 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
function easeInElement(el: Element) {
el.classList.add('opacity-0', 'transition-opacity', 'duration-300');
el.classList.remove('hidden');
setTimeout(() => {
el.classList.remove('opacity-0');
});
}
function showHideAuthElements(hideOrShow: 'hide' | 'show' = 'hide') {
document.querySelectorAll('[data-auth-required]').forEach((el) => {
if (hideOrShow === 'hide') {
el.classList.add('hidden');
} else {
easeInElement(el);
}
});
}
function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
document.querySelectorAll('[data-guest-required]').forEach((el) => {
if (hideOrShow === 'hide') {
el.classList.add('hidden');
} else {
easeInElement(el);
}
});
}
// Prepares the UI for the user who is logged in
function handleGuest() {
const authenticatedRoutes = [
'/settings/update-profile',
'/settings/update-password',
];
showHideAuthElements('hide');
showHideGuestElements('show');
// If the user is on an authenticated route, redirect them to the home page
if (authenticatedRoutes.includes(window.location.pathname)) {
window.location.href = '/';
}
}
// Prepares the UI for the user who is logged out
function handleAuthenticated() {
const guestRoutes = [
'/login',
'/signup',
'/verify-account',
'/verification-pending',
'/reset-password',
'/forgot-password',
];
showHideGuestElements('hide');
showHideAuthElements('show');
// If the user is on a guest route, redirect them to the home page
if (guestRoutes.includes(window.location.pathname)) {
window.location.href = '/';
}
}
export function handleAuthRequired() {
const token = Cookies.get(TOKEN_COOKIE_NAME);
if (token) {
handleAuthenticated();
} else {
handleGuest();
}
}
window.setTimeout(() => {
handleAuthRequired();
}, 0);

View File

@@ -1,7 +1,8 @@
---
import BestPracticeHint from './BestPracticeHint.astro';
import DownloadPopup from './DownloadPopup.astro';
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import SubscribePopup from './SubscribePopup.astro';
export interface Props {
@@ -15,23 +16,22 @@ const { title, description, bestPracticeId, isUpcoming = false } = Astro.props;
const isBestPracticeReady = !isUpcoming;
---
<DownloadPopup />
<SubscribePopup />
<LoginPopup />
<div class='border-b'>
<div class='py-5 sm:py-12 container relative'>
<div class='mt-0 mb-3 sm:mb-6'>
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
<div class='container relative py-5 sm:py-12'>
<div class='mb-3 mt-0 sm:mb-6'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
</h1>
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
</div>
<div class='flex justify-between'>
<div class='flex gap-1 sm:gap-2'>
<a
href='/best-practices'
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
aria-label='Back to All Best Practices'
>
&larr;<span class='hidden sm:inline'>&nbsp;All Best Practices</span>
@@ -40,22 +40,42 @@ const isBestPracticeReady = !isUpcoming;
{
isBestPracticeReady && (
<button
data-popup='download-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
aria-label='Download Best Practice'
data-guest-required
data-popup='login-popup'
class='hidden inline-flex items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Download Roadmap'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Download Best Practice Popup'
ga-label='Download Roadmap Popup'
>
<Icon icon='download' />
<span class='hidden sm:inline ml-2'>Download</span>
<span class='ml-2 hidden sm:inline'>Download</span>
</button>
)
}
{
isBestPracticeReady && (
<a
data-auth-required
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Download Roadmap'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Download Roadmap Popup'
target="_blank"
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</a>
)
}
<button
data-popup='subscribe-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
data-guest-required
data-popup='login-popup'
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Subscribe for Updates'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
@@ -71,7 +91,7 @@ const isBestPracticeReady = !isUpcoming;
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
aria-label='Suggest Changes'
>
<Icon icon='comment' class='h-3 w-3' />

View File

@@ -10,7 +10,7 @@ const { breadcrumbs, roadmapId } = Astro.props;
---
<div class='py-7 pb-6'>
<!-- Desktop breadcrums -->
<!-- Desktop breadcrumbs -->
<p class='text-gray-500 container hidden sm:block'>
{
breadcrumbs.map((breadcrumb, counter) => {

View File

@@ -1,50 +0,0 @@
---
import Popup from './Popup/Popup.astro';
import CaptchaFields from './Captcha/CaptchaFields.astro';
---
<Popup id='download-popup' title='Download' subtitle='Enter your email below to receive the download link.'>
<form
action='https://news.roadmap.sh/subscribe'
method='POST'
accept-charset='utf-8'
target='_blank'
captcha-form
>
<input type='hidden' name='gdpr' value='true' />
<input
type='email'
name='email'
id='email'
required
autofocus
class='w-full rounded-md border text-md py-2.5 px-3 mb-2'
placeholder='Enter your Email'
/>
<CaptchaFields />
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
<input type='hidden' name='subform' value='yes' />
<button
type='submit'
name='submit'
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2'
submit-download-form
>
Send Link
</button>
</form>
</Popup>
<script>
document.querySelector('[submit-download-form]')?.addEventListener('click', () => {
window.fireEvent({
category: 'Subscription',
action: 'Submitted Popup Form',
label: 'Download Roadmap Popup',
});
});
</script>

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export interface Props {
question: string;

View File

@@ -1,5 +1,5 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
---
<div class='py-6 sm:py-16 pb-10 bg-slate-900 text-white'>

View File

@@ -17,7 +17,9 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
<div
id='resource-svg-wrap'
style={dimensions ? `--aspect-ratio:${dimensions.width}/${dimensions.height}` : null}
style={dimensions
? `--aspect-ratio:${dimensions.width}/${dimensions.height}`
: null}
data-resource-type={resourceType}
data-resource-id={resourceId}
data-json-url={jsonUrl}
@@ -27,4 +29,4 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
</div>
</div>
<script src='./renderer.js'></script>
<script src='./renderer.ts'></script>

View File

@@ -1,6 +1,18 @@
import { wireframeJSONToSVG } from 'roadmap-renderer';
import {
renderResourceProgress,
ResourceType,
} from '../../lib/resource-progress';
export class Renderer {
resourceId: string;
resourceType: string;
jsonUrl: string;
loaderHTML: string | null;
containerId: string;
loaderId: string;
constructor() {
this.resourceId = '';
this.resourceType = '';
@@ -32,12 +44,12 @@ export class Renderer {
}
// Clone it so we can use it later
this.loaderHTML = this.loaderEl.innerHTML;
this.loaderHTML = this.loaderEl!.innerHTML;
const dataset = this.containerEl.dataset;
this.resourceType = dataset.resourceType;
this.resourceId = dataset.resourceId;
this.jsonUrl = dataset.jsonUrl;
this.resourceType = dataset.resourceType!;
this.resourceId = dataset.resourceId!;
this.jsonUrl = dataset.jsonUrl!;
return true;
}
@@ -46,13 +58,17 @@ export class Renderer {
* @param { string } jsonUrl
* @returns {Promise<SVGElement>}
*/
jsonToSvg(jsonUrl) {
jsonToSvg(jsonUrl: string) {
if (!jsonUrl) {
console.error('jsonUrl not defined in frontmatter');
return null;
}
this.containerEl.innerHTML = this.loaderHTML;
if (!this.containerEl) {
return null;
}
this.containerEl.innerHTML = this.loaderHTML!;
return fetch(jsonUrl)
.then((res) => {
@@ -64,9 +80,19 @@ export class Renderer {
});
})
.then((svg) => {
this.containerEl.replaceChildren(svg);
this.containerEl?.replaceChildren(svg);
})
.then(() => {
return renderResourceProgress(
this.resourceType as ResourceType,
this.resourceId
);
})
.catch((error) => {
if (!this.containerEl) {
return;
}
const message = `
<strong>There was an error.</strong><br>
@@ -74,7 +100,6 @@ export class Renderer {
${error.message} <br /> ${error.stack}
`;
this.containerEl.innerHTML = `<div class="error py-5 text-center text-red-600 mx-auto">${message}</div>`;
});
}
@@ -94,16 +119,16 @@ export class Renderer {
}
}
switchRoadmap(newJsonUrl) {
const newJsonFileSlug = newJsonUrl.split('/').pop().replace('.json', '');
switchRoadmap(newJsonUrl: string) {
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
// Update the URL and attach the new roadmap type
if (window?.history?.pushState) {
const url = new URL(window.location);
const url = new URL(window.location.href);
const type = this.resourceType[0]; // r for roadmap, b for best-practices
url.searchParams.delete(type);
url.searchParams.set(type, newJsonFileSlug);
url.searchParams.set(type, newJsonFileSlug!);
window.history.pushState(null, '', url.toString());
}
@@ -119,13 +144,13 @@ export class Renderer {
label: `${newJsonFileSlug}`,
});
this.jsonToSvg(newJsonUrl).then(() => {
this.containerEl.setAttribute('style', '');
this.jsonToSvg(newJsonUrl)?.then(() => {
this.containerEl?.setAttribute('style', '');
});
}
handleSvgClick(e) {
const targetGroup = e.target.closest('g') || {};
handleSvgClick(e: any) {
const targetGroup = e.target?.closest('g') || {};
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
if (!groupId) {
return;
@@ -167,6 +192,7 @@ export class Renderer {
detail: {
topicId: normalizedGroupId,
resourceId: this.resourceId,
resourceType: this.resourceType,
},
})
);
@@ -175,6 +201,7 @@ export class Renderer {
init() {
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
window.addEventListener('click', this.handleSvgClick);
// window.addEventListener('contextmenu', this.handleSvgClick);
}
}

View File

@@ -1,5 +1,5 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
---
<div class='flex justify-center w-full'>

View File

@@ -1,79 +0,0 @@
---
import Icon from './Icon.astro';
---
<div class='bg-slate-900 text-white py-5 sm:py-8'>
<nav class='container flex items-center justify-between'>
<a class='font-medium text-lg flex items-center text-white' href='/'>
<Icon icon='logo' />
<span class='ml-3'>roadmap.sh</span>
</a>
<!-- Desktop navigation items -->
<ul class='hidden sm:flex space-x-5'>
<li>
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-gray-400 hover:text-white'>Best Practices</a>
</li>
<li>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
</li>
<li>
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
</li>
<li>
<a
class='py-2 px-4 text-sm font-regular rounded-full bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white'
href='/signup'
>
Subscribe
</a>
</li>
</ul>
<!-- Mobile Navigation Button -->
<button class='text-gray-400 hover:text-gray-50 block sm:hidden cursor-pointer' aria-label='Menu' show-mobile-nav>
<Icon icon='hamburger' />
</button>
<!-- Mobile Navigation Items -->
<div class='fixed top-0 bottom-0 left-0 right-0 z-40 bg-slate-900 items-center flex hidden' mobile-nav>
<button
close-mobile-nav
class='text-gray-400 hover:text-gray-50 block cursor-pointer absolute top-6 right-6'
aria-label='Close Menu'
>
<Icon icon='close' />
</button>
<ul class='flex flex-col gap-2 md:gap-3 items-center w-full'>
<li>
<a href='/roadmaps' class='text-xl md:text-lg hover:text-blue-300'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-xl md:text-lg hover:text-blue-300'>Best Practices</a>
</li>
<li>
<a href='/guides' class='text-xl md:text-lg hover:text-blue-300'>Guides</a>
</li>
<li>
<a href='/videos' class='text-xl md:text-lg hover:text-blue-300'>Videos</a>
</li>
<li>
<a href='/signup' class='text-xl md:text-lg text-red-300 hover:text-red-400'>Subscribe</a>
</li>
</ul>
</div>
</nav>
</div>
<script>
document.querySelector('[show-mobile-nav]')?.addEventListener('click', () => {
document.querySelector('[mobile-nav]')?.classList.remove('hidden');
});
document.querySelector('[close-mobile-nav]')?.addEventListener('click', () => {
document.querySelector('[mobile-nav]')?.classList.add('hidden');
});
</script>

View File

@@ -0,0 +1,44 @@
---
import Icon from '../AstroIcon.astro';
---
<div class='relative hidden' data-auth-required>
<button
class='flex h-8 w-28 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600'
type='button'
data-account-button
>
<span class='inline-flex items-center gap-1.5'>
Account
<Icon
icon='chevron-down'
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
/>
</span>
</button>
<div
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
data-account-dropdown
>
<ul>
<li class='px-1'>
<a
href='/settings/update-profile'
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
>
Settings
</a>
</li>
<li class='px-1'>
<button
class='block w-full rounded px-4 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700'
type='button'
data-logout-button
>
Logout
</button>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,133 @@
---
import Icon from '../AstroIcon.astro';
import AccountDropdown from './AccountDropdown.astro';
---
<div class='bg-slate-900 py-5 text-white sm:py-8'>
<nav class='container flex items-center justify-between'>
<a class='flex items-center text-lg font-medium text-white' href='/'>
<Icon icon='logo' />
<span class='ml-3 hidden md:block'>roadmap.sh</span>
</a>
<!-- Desktop navigation items -->
<ul class='hidden space-x-5 sm:flex sm:items-center'>
<li>
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
</li>
<li>
<a href='/best-practices' class='text-gray-400 hover:text-white'
>Best Practices</a
>
</li>
<li>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
</li>
<li>
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
</li>
</ul>
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
<li data-guest-required class='hidden'>
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
</li>
<li>
<AccountDropdown />
<a
data-guest-required
class='flex hidden h-8 w-28 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-blue-700 px-4 py-2 text-sm font-medium text-white hover:from-blue-500 hover:to-blue-600'
href='/signup'
>
<span>Sign Up</span>
</a>
</li>
</ul>
<!-- Mobile Navigation Button -->
<button
class='block cursor-pointer text-gray-400 hover:text-gray-50 sm:hidden'
aria-label='Menu'
data-show-mobile-nav
>
<Icon icon='hamburger' />
</button>
<!-- Mobile Navigation Items -->
<div
class='fixed bottom-0 left-0 right-0 top-0 z-40 flex hidden items-center bg-slate-900'
data-mobile-nav
>
<button
data-close-mobile-nav
class='absolute right-6 top-6 block cursor-pointer text-gray-400 hover:text-gray-50'
aria-label='Close Menu'
>
<Icon icon='close' />
</button>
<ul class='flex w-full flex-col items-center gap-2 md:gap-3'>
<li>
<a href='/roadmaps' class='text-xl hover:text-blue-300 md:text-lg'>
Roadmaps
</a>
</li>
<li>
<a
href='/best-practices'
class='text-xl hover:text-blue-300 md:text-lg'
>
Best Practices
</a>
</li>
<li>
<a href='/guides' class='text-xl hover:text-blue-300 md:text-lg'>
Guides
</a>
</li>
<li>
<a href='/videos' class='text-xl hover:text-blue-300 md:text-lg'>
Videos
</a>
</li>
<!-- Links for logged in users -->
<li data-auth-required class='hidden'>
<a
href='/settings/update-profile'
class='text-xl hover:text-blue-300 md:text-lg'
>
Settings
</a>
</li>
<li data-auth-required class='hidden'>
<button
data-logout-button
class='text-xl text-red-300 hover:text-red-400 md:text-lg'
>
Logout
</button>
</li>
<li>
<a
data-guest-required
href='/signup'
class='hidden text-xl text-white md:text-lg'
>
Login
</a>
</li>
<li>
<a
data-guest-required
href='/signup'
class='hidden text-xl text-green-300 hover:text-green-400 md:text-lg'
>
Sign Up
</a>
</li>
</ul>
</div>
</nav>
</div>
<script src='./navigation.ts'></script>

View File

@@ -0,0 +1,39 @@
import Cookies from 'js-cookie';
import { handleAuthRequired } from '../Authenticator/authenticator';
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
export function logout() {
Cookies.remove(TOKEN_COOKIE_NAME);
// Reloading will automatically redirect the user if required
window.location.reload();
}
function bindEvents() {
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const dataset = {
...target.dataset,
...target.closest('button')?.dataset,
};
// If the user clicks on the logout button, remove the token cookie
if (dataset.logoutButton !== undefined) {
logout();
} else if (dataset.showMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
} else if (dataset.closeMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
}
});
document
.querySelector('[data-account-button]')
?.addEventListener('click', (e) => {
e.stopPropagation();
document
.querySelector('[data-account-dropdown]')
?.classList.toggle('hidden');
});
}
bindEvents();

View File

@@ -1,6 +1,6 @@
---
import { getFormattedStars } from '../lib/github';
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
---

View File

@@ -0,0 +1,29 @@
import { useStore } from '@nanostores/preact';
import { pageLoadingMessage } from '../stores/page';
import SpinnerIcon from '../icons/spinner.svg';
export function PageProgress() {
const $pageLoadingMessage = useStore(pageLoadingMessage);
if (!$pageLoadingMessage) {
return null;
}
return (
<div>
{/* Tailwind based spinner for full page */}
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
<div class="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
<img
src={SpinnerIcon}
alt="Loading"
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
/>
<h1 className="ml-2">
{$pageLoadingMessage}
<span className="animate-pulse">...</span>
</h1>
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export interface Props {
id: string;

View File

@@ -1,9 +1,8 @@
---
import DownloadPopup from './DownloadPopup.astro';
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
import RoadmapHint from './RoadmapHint.astro';
import RoadmapNote from './RoadmapNote.astro';
import SubscribePopup from './SubscribePopup.astro';
import TopicSearch from './TopicSearch/TopicSearch.astro';
import YouTubeAlert from './YouTubeAlert.astro';
@@ -18,23 +17,31 @@ export interface Props {
hasTopics?: boolean;
}
const { title, description, roadmapId, tnsBannerLink, isUpcoming = false, hasSearch = false, note, hasTopics = false } = Astro.props;
const {
title,
description,
roadmapId,
tnsBannerLink,
isUpcoming = false,
hasSearch = false,
note,
hasTopics = false,
} = Astro.props;
const isRoadmapReady = !isUpcoming;
---
<DownloadPopup />
<SubscribePopup />
<LoginPopup />
<div class='border-b'>
<div class='py-5 sm:py-12 container relative'>
<div class='container relative py-5 sm:py-12'>
<YouTubeAlert />
<div class='mt-0 mb-3 sm:mb-4 sm:mt-4'>
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
<div class='mb-3 mt-0 sm:mb-4 sm:mt-4'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
</h1>
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
</div>
<div class='flex justify-between'>
@@ -44,33 +51,42 @@ const isRoadmapReady = !isUpcoming;
<>
<a
href='/roadmaps'
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
aria-label='Back to All Roadmaps'
>
&larr;<span class='hidden sm:inline'>&nbsp;All Roadmaps</span>
</a>
{isRoadmapReady && (
<button
data-popup='download-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
aria-label='Download Roadmap'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Download Roadmap Popup'
>
<Icon icon='download' />
<span class='hidden sm:inline ml-2'>Download</span>
</button>
<>
<button
data-guest-required
data-popup='login-popup'
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Download Roadmap'
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</button>
<a
data-auth-required
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Download Roadmap'
target='_blank'
href={`/pdfs/roadmaps/${roadmapId}.pdf`}
>
<Icon icon='download' />
<span class='ml-2 hidden sm:inline'>Download</span>
</a>
</>
)}
<button
data-popup='subscribe-popup'
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
data-guest-required
data-popup='login-popup'
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
aria-label='Subscribe for Updates'
ga-category='Subscription'
ga-action='Clicked Popup Opener'
ga-label='Subscribe Roadmap Popup'
>
<Icon icon='email' />
<span class='ml-2'>Subscribe</span>
@@ -83,7 +99,7 @@ const isRoadmapReady = !isUpcoming;
hasSearch && (
<a
href={`/${roadmapId}`}
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
aria-label='Back to Visual Roadmap'
>
&larr;
@@ -98,7 +114,7 @@ const isRoadmapReady = !isUpcoming;
<a
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
target='_blank'
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
aria-label='Suggest Changes'
>
<Icon icon='comment' class='h-3 w-3' />
@@ -110,7 +126,11 @@ const isRoadmapReady = !isUpcoming;
</div>
<!-- Desktop: Roadmap Resources - Alert -->
{hasTopics && <RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />}
{
hasTopics && (
<RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />
)
}
{hasSearch && <TopicSearch />}
</div>

View File

@@ -1,5 +1,5 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
export interface Props {
roadmapId: string;

View File

@@ -0,0 +1,81 @@
---
import Icon from '../AstroIcon.astro';
const { pageUrl, name } = Astro.props;
export interface Props {
pageUrl: string;
name: string;
}
---
<div
class='container flex min-h-[calc(100vh-37px-70px)] items-stretch sm:min-h-[calc(100vh-37px-96px)]'
>
<aside class='hidden w-56 border-r border-slate-200 py-10 pr-5 md:block'>
<nav>
<ul class='space-y-1'>
<li>
<a
href='/settings/update-profile'
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}`
>Profile</a
>
</li>
<li>
<a
href='/settings/update-password'
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}`
>Security</a
>
</li>
</ul>
</nav>
</aside>
<div class='grow py-10 pl-0 md:p-10 md:pr-0'>
<div class='relative mb-5 md:hidden'>
<button
class='flex h-10 w-full items-center justify-between rounded-md bg-slate-800 px-2 text-center font-medium text-slate-100'
id='settings-menu'
>
{name}
<Icon icon='dropdown' />
</button>
<ul
id='settings-menu-dropdown'
class='absolute mt-1 hidden w-full space-y-1.5 rounded-md bg-white p-2 shadow-lg'
>
<li>
<a
href='/settings/update-profile'
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}`
>Profile</a
>
</li>
<li>
<a
href='/settings/update-password'
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}`
>Change password</a
>
</li>
</ul>
</div>
<slot />
</div>
</div>
<script>
const menuButton = document.getElementById('settings-menu');
const menuDropdown = document.getElementById('settings-menu-dropdown');
menuButton?.addEventListener('click', () => {
menuDropdown?.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!menuButton?.contains(e.target as Node)) {
menuDropdown?.classList.add('hidden');
}
});
</script>

View File

@@ -0,0 +1,175 @@
import { useCallback, useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpGet, httpPost } from '../../lib/http';
import { pageLoadingMessage } from '../../stores/page';
export default function UpdatePasswordForm() {
const [authProvider, setAuthProvider] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(true);
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
setSuccess('');
if (newPassword !== newPasswordConfirmation) {
setError('Passwords do not match');
setIsLoading(false);
return;
}
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-update-password`,
{
oldPassword: authProvider === 'email' ? currentPassword : 'social-auth',
password: newPassword,
confirmPassword: newPasswordConfirmation,
}
);
if (error) {
setError(error.message || 'Something went wrong');
setIsLoading(false);
return;
}
setError('');
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirmation('');
setSuccess('Password updated successfully');
setIsLoading(false);
};
const loadProfile = async () => {
setIsLoading(true);
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
const { authProvider } = response;
setAuthProvider(authProvider);
setIsLoading(false);
};
useEffect(() => {
pageLoadingMessage.set('Loading profile');
loadProfile().finally(() => {
pageLoadingMessage.set('');
});
}, []);
return (
<form onSubmit={handleSubmit}>
<h2 className="text-3xl font-bold sm:text-4xl">Password</h2>
<p className="mt-2">Use the form below to update your password.</p>
<div className="mt-8 space-y-4">
{authProvider === 'email' && (
<div className="flex w-full flex-col">
<label
for="current-password"
className="text-sm leading-none text-slate-500"
>
Current Password
</label>
<input
disabled={authProvider !== 'email'}
type="password"
name="current-password"
id="current-password"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-100"
required
minLength={6}
placeholder="Current password"
value={currentPassword}
onInput={(e) =>
setCurrentPassword((e.target as HTMLInputElement).value)
}
/>
</div>
)}
<div className="flex w-full flex-col">
<label
for="new-password"
className="text-sm leading-none text-slate-500"
>
New Password
</label>
<input
type="password"
name="new-password"
id="new-password"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="New password"
value={newPassword}
onInput={(e) =>
setNewPassword((e.target as HTMLInputElement).value)
}
/>
</div>
<div className="flex w-full flex-col">
<label
for="new-password-confirmation"
className="text-sm leading-none text-slate-500"
>
New Password Confirm
</label>
<input
type="password"
name="new-password-confirmation"
id="new-password-confirmation"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
placeholder="New password confirm"
value={newPasswordConfirmation}
onInput={(e) =>
setNewPasswordConfirmation((e.target as HTMLInputElement).value)
}
/>
</div>
{error && (
<p class="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
{success && (
<p class="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
{success}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Update Password'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,203 @@
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { pageLoadingMessage } from '../../stores/page';
export function UpdateProfileForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [github, setGithub] = useState('');
const [twitter, setTwitter] = useState('');
const [linkedin, setLinkedin] = useState('');
const [website, setWebsite] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
setSuccess('');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`,
{
name,
github: github || undefined,
linkedin: linkedin || undefined,
twitter: twitter || undefined,
website: website || undefined,
}
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
await loadProfile();
setSuccess('Profile updated successfully');
};
const loadProfile = async () => {
// Set the loading state
setIsLoading(true);
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
const { name, email, links } = response;
setName(name);
setEmail(email);
setGithub(links?.github || '');
setLinkedin(links?.linkedin || '');
setTwitter(links?.twitter || '');
setWebsite(links?.website || '');
setIsLoading(false);
};
// Make a request to the backend to fill in the form with the current values
useEffect(() => {
pageLoadingMessage.set('Loading profile');
loadProfile().finally(() => {
pageLoadingMessage.set('');
});
}, []);
return (
<form onSubmit={handleSubmit}>
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
<p className="mt-2">Update your profile details below.</p>
<div className="mt-8 space-y-4">
<div className="flex w-full flex-col">
<label
for="name"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Name
</label>
<input
type="text"
name="name"
id="name"
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="John Doe"
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label
for="email"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Email
</label>
<input
type="email"
name="email"
id="email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
placeholder="john@example.com"
value={email}
/>
</div>
<div className="flex w-full flex-col">
<label for="github" className="text-sm leading-none text-slate-500">
Github
</label>
<input
type="text"
name="github"
id="github"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://github.com/username"
value={github}
onInput={(e) => setGithub((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label for="twitter" className="text-sm leading-none text-slate-500">
Twitter
</label>
<input
type="text"
name="twitter"
id="twitter"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://twitter.com/username"
value={twitter}
onInput={(e) => setTwitter((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label for="linkedin" className="text-sm leading-none text-slate-500">
LinkedIn
</label>
<input
type="text"
name="linkedin"
id="linkedin"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://www.linkedin.com/in/username/"
value={linkedin}
onInput={(e) => setLinkedin((e.target as HTMLInputElement).value)}
/>
</div>
<div className="flex w-full flex-col">
<label for="website" className="text-sm leading-none text-slate-500">
Website
</label>
<input
type="text"
name="website"
id="website"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="https://example.com"
value={website}
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
/>
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
{success && (
<p className="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
{success}
</p>
)}
<button
type="submit"
disabled={isLoading}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Continue'}
</button>
</div>
</form>
);
}

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export interface Props {
pageUrl: string;

View File

@@ -1,6 +1,6 @@
---
import type { GAEventType } from '../Analytics/analytics';
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
export type SponsorType = {
url: string;

View File

@@ -1,41 +0,0 @@
---
import Popup from './Popup/Popup.astro';
import CaptchaFields from './Captcha/CaptchaFields.astro';
---
<Popup id='subscribe-popup' title='Subscribe' subtitle='Enter your email below to receive updates.'>
<form
action='https://news.roadmap.sh/subscribe'
method='POST'
accept-charset='utf-8'
target='_blank'
captcha-form
>
<input type='hidden' name='gdpr' value='true' />
<input
type='email'
name='email'
required
autofocus
class='w-full rounded-md border text-md py-2.5 px-3 mb-2'
placeholder='Enter your Email'
/>
<CaptchaFields />
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
<input type='hidden' name='subform' value='yes' />
<button
type='submit'
name='submit'
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2'
ga-category='Subscription'
ga-action='Submitted Popup Form'
ga-label='Subscribe Roadmap Popup'
>
Subscribe
</button>
</form>
</Popup>

View File

@@ -0,0 +1,261 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import SpinnerIcon from '../../icons/spinner.svg';
import CheckIcon from '../../icons/check.svg';
import ResetIcon from '../../icons/reset.svg';
import CloseIcon from '../../icons/close.svg';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useLoadTopic } from '../../hooks/use-load-topic';
import { httpGet } from '../../lib/http';
import { isLoggedIn } from '../../lib/jwt';
import {
isTopicDone,
renderTopicProgress,
ResourceType,
toggleMarkTopicDone as toggleMarkTopicDoneApi,
} from '../../lib/resource-progress';
import { useKeydown } from '../../hooks/use-keydown';
import { useToggleTopic } from '../../hooks/use-toggle-topic';
import { pageLoadingMessage } from '../../stores/page';
export function TopicDetail() {
const [isActive, setIsActive] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [topicHtml, setTopicHtml] = useState('');
const [isDone, setIsDone] = useState<boolean>();
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
const isGuest = useMemo(() => !isLoggedIn(), []);
const topicRef = useRef<HTMLDivElement>(null);
// Details of the currently loaded topic
const [topicId, setTopicId] = useState('');
const [resourceId, setResourceId] = useState('');
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
const showLoginPopup = () => {
const popupEl = document.querySelector(`#login-popup`);
if (!popupEl) {
return;
}
popupEl.classList.remove('hidden');
popupEl.classList.add('flex');
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]');
if (focusEl) {
focusEl.focus();
}
};
const toggleMarkTopicDone = (isDone: boolean) => {
setIsUpdatingProgress(true);
toggleMarkTopicDoneApi({ topicId, resourceId, resourceType }, isDone)
.then(() => {
setIsDone(isDone);
setIsActive(false);
renderTopicProgress(topicId, isDone);
})
.catch((err) => {
alert(err.message);
console.error(err);
})
.finally(() => {
setIsUpdatingProgress(false);
});
};
// Load the topic status when the topic detail is active
useEffect(() => {
if (!topicId || !resourceId || !resourceType) {
return;
}
setIsUpdatingProgress(true);
isTopicDone({ topicId, resourceId, resourceType })
.then((status: boolean) => {
setIsUpdatingProgress(false);
setIsDone(status);
})
.catch(console.error);
}, [topicId, resourceId, resourceType]);
// Close the topic detail when user clicks outside the topic detail
useOutsideClick(topicRef, () => {
setIsActive(false);
});
useKeydown('Escape', () => {
setIsActive(false);
});
// Toggle topic is available even if the component UI is not active
// This is used on the best practice screen where we have the checkboxes
// to mark the topic as done/undone.
useToggleTopic(({ topicId, resourceType, resourceId }) => {
if (isGuest) {
showLoginPopup();
return;
}
pageLoadingMessage.set('Updating');
// Toggle the topic status
isTopicDone({ topicId, resourceId, resourceType })
.then((oldIsDone) => {
return toggleMarkTopicDoneApi(
{
topicId,
resourceId,
resourceType,
},
!oldIsDone
);
})
.then((newIsDone) => renderTopicProgress(topicId, newIsDone))
.catch((err) => {
alert(err.message);
console.error(err);
})
.finally(() => {
pageLoadingMessage.set('');
});
});
// Load the topic detail when the topic detail is active
useLoadTopic(({ topicId, resourceType, resourceId }) => {
setIsLoading(true);
setIsActive(true);
setTopicId(topicId);
setResourceType(resourceType);
setResourceId(resourceId);
const topicPartial = topicId.replaceAll(':', '/');
const topicUrl =
resourceType === 'roadmap'
? `/${resourceId}/${topicPartial}`
: `/best-practices/${resourceId}/${topicPartial}`;
httpGet<string>(
topicUrl,
{},
{
headers: {
Accept: 'text/html',
},
}
)
.then(({ response }) => {
if (!response) {
setError('Topic not found.');
return;
}
// It's full HTML with page body, head etc.
// We only need the inner HTML of the #main-content
const node = new DOMParser().parseFromString(response, 'text/html');
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
setIsLoading(false);
setTopicHtml(topicHtml);
})
.catch((err) => {
setError('Something went wrong. Please try again later.');
setIsLoading(false);
});
});
if (!isActive) {
return null;
}
return (
<div>
<div
ref={topicRef}
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
>
{isLoading && (
<div className="flex w-full justify-center">
<img
src={SpinnerIcon}
alt="Loading"
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12"
/>
</div>
)}
{!isLoading && !error && (
<>
{/* Actions for the topic */}
<div className="mb-2">
{isGuest && (
<button
data-popup="login-popup"
className="inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700"
onClick={() => setIsActive(false)}
>
<img alt="Check" src={CheckIcon} />
<span className="ml-2">Mark as Done</span>
</button>
)}
{!isGuest && (
<>
{isUpdatingProgress && (
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
<img
alt="Check"
class="h-4 w-4 animate-spin"
src={SpinnerIcon}
/>
<span className="ml-2">Updating Status..</span>
</button>
)}
{!isUpdatingProgress && !isDone && (
<button
className="inline-flex items-center rounded-md border border-green-600 bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700"
onClick={() => toggleMarkTopicDone(true)}
>
<img alt="Check" src={CheckIcon} />
<span className="ml-2">Mark as Done</span>
</button>
)}
{!isUpdatingProgress && isDone && (
<button
className="inline-flex items-center rounded-md border border-red-600 bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700"
onClick={() => toggleMarkTopicDone(false)}
>
<img alt="Check" class="h-4" src={ResetIcon} />
<span className="ml-2">Mark as Pending</span>
</button>
)}
</>
)}
<button
type="button"
id="close-topic"
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
onClick={() => setIsActive(false)}
>
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
</button>
</div>
{/* Topic Content */}
<div
id="topic-content"
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5"
dangerouslySetInnerHTML={{ __html: topicHtml }}
></div>
</>
)}
</div>
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
import Loader from '../Loader.astro';
export interface Props {
@@ -11,7 +11,7 @@ const { contentContributionLink } = Astro.props;
<div id='topic-overlay' class='hidden'>
<div
class='fixed top-0 right-0 z-40 h-screen p-4 sm:p-6 overflow-y-auto bg-white w-full sm:max-w-[600px]'
class='fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6'
tabindex='-1'
id='topic-body'
>
@@ -19,33 +19,39 @@ const { contentContributionLink } = Astro.props;
<Loader />
</div>
<div id='topic-actions' class='hidden mb-2'>
<button
id='mark-topic-done'
ga-category='TopicClick'
ga-action='topic/mark-completion'
ga-label='done'
class='bg-green-600 text-white p-1 px-2 text-sm rounded-md hover:bg-green-700 inline-flex items-center'
>
<Icon icon='check' />
<span class='ml-2'>Mark as Done</span>
</button>
<div id='topic-actions' class='mb-2 hidden'>
<div data-guest-required class='hidden'>
<button
data-popup='login-popup'
class='inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700'
>
<Icon icon='check' />
<span class='ml-2'>Mark as Done</span>
</button>
</div>
<button
id='mark-topic-pending'
ga-category='TopicClick'
ga-action='topic/mark-completion'
ga-label='pending'
class='hidden bg-red-600 text-white p-1 px-2 text-sm rounded-md hover:bg-red-700 inline-flex items-center'
>
<Icon icon='reset' />
<span class='ml-2'>Mark as Pending</span>
</button>
<div data-auth-required>
<button
id='mark-topic-done'
class='inline-flex hidden items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700'
>
<Icon icon='check' />
<span class='ml-2'>Mark as Done</span>
</button>
<button
id='mark-topic-pending'
class='inline-flex hidden items-center rounded-md bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700'
>
<Icon icon='reset' />
<span class='ml-2'>Mark as Pending</span>
</button>
</div>
<button
type='button'
id='close-topic'
class='text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center'
class='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'
>
<Icon icon='close' />
</button>
@@ -53,24 +59,24 @@ const { contentContributionLink } = Astro.props;
<div
id='topic-content'
class='prose prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-quoteless prose-blockquote:font-normal prose-h1:mt-7 prose-h1:mb-2.5 prose-p:mt-0 prose-p:mb-2 prose-li:m-0 prose-li:mb-0.5 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mt-[10px] prose-h3:mb-[5px]'
class='prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5'
>
</div>
<p
id='contrib-meta'
class='text-gray-400 text-sm border-t pt-3 mt-10 hidden'
class='mt-10 hidden border-t pt-3 text-sm text-gray-400'
>
We are still working on this page. You can contribute by submitting a
brief description and a few links to learn more about this topic <a
target='_blank'
class='underline text-blue-700'
class='text-blue-700 underline'
href={contentContributionLink}>on GitHub repository.</a
>.
</p>
</div>
<div class='bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-30'>
<div class='fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80'>
</div>
</div>
<script src="./topic.js" />
<script src='./topic.js'></script>

View File

@@ -29,7 +29,6 @@ export class Topic {
this.markAsDone = this.markAsDone.bind(this);
this.markAsPending = this.markAsPending.bind(this);
this.querySvgElementsByTopicId = this.querySvgElementsByTopicId.bind(this);
this.rightClickListener = this.rightClickListener.bind(this);
this.isTopicDone = this.isTopicDone.bind(this);
this.init = this.init.bind(this);
@@ -63,20 +62,6 @@ export class Topic {
return document.getElementById(this.overlayId);
}
rightClickListener(e) {
const groupId = e.target?.closest('g')?.dataset?.groupId;
if (!groupId) {
return;
}
e.preventDefault();
if (this.isTopicDone(groupId)) {
this.markAsPending(groupId);
} else {
this.markAsDone(groupId);
}
}
resetDOM(hideOverlay = false) {
if (hideOverlay) {
this.overlayEl.classList.add('hidden');
@@ -99,7 +84,8 @@ export class Topic {
isTopicDone(topicId) {
const normalizedGroup = topicId.replace(/^\d+-/, '');
return localStorage.getItem(normalizedGroup) === 'done';
const el = document.querySelector(`[data-group-id$="-${normalizedGroup}"]`);
return el?.classList.contains('done');
}
/**
@@ -152,9 +138,9 @@ export class Topic {
const isDone = localStorage.getItem(topicId) === 'done';
if (isDone) {
this.markAsPending(topicId);
this.markAsPending(topicId, bestPracticeId, 'best-practice');
} else {
this.markAsDone(topicId);
this.markAsDone(topicId, bestPracticeId, 'best-practice');
}
}
@@ -165,7 +151,7 @@ export class Topic {
return;
}
this.markAsPending(topicId);
this.markAsPending(topicId, bestPracticeId, 'best-practice');
}
handleBestPracticeTopicClick(e) {
@@ -244,22 +230,34 @@ export class Topic {
return matchingElements;
}
markAsDone(topicId) {
async markAsDone(topicId, resourceId, resourceType) {
const updatedTopicId = topicId.replace(/^\d+-/, '');
localStorage.setItem(updatedTopicId, 'done');
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
item?.classList?.add('done');
});
const { response, error } = {};
if (response) {
this.close();
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
item?.classList?.add('done');
});
} else {
console.error(error);
}
}
markAsPending(topicId) {
async markAsPending(topicId, resourceId, resourceType) {
const updatedTopicId = topicId.replace(/^\d+-/, '');
localStorage.removeItem(updatedTopicId);
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
item?.classList?.remove('done');
});
const { response, error } = {};
if (response) {
this.close();
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
item?.classList?.remove('done');
});
} else {
console.error(error);
}
}
handleOverlayClick(e) {
@@ -274,22 +272,32 @@ export class Topic {
e.target.id === this.markTopicDoneId ||
e.target.closest(`#${this.markTopicDoneId}`);
if (isClickedDone) {
this.markAsDone(this.activeTopicId);
this.close();
this.markAsDone(
this.activeTopicId,
this.activeResourceId,
this.activeResourceType
);
// this.close();
}
const isClickedPending =
e.target.id === this.markTopicPendingId ||
e.target.closest(`#${this.markTopicPendingId}`);
if (isClickedPending) {
this.markAsPending(this.activeTopicId);
this.close();
this.markAsPending(
this.activeTopicId,
this.activeResourceId,
this.activeResourceType
);
// this.close();
}
const isClickedPopupOpener =
e.target.dataset['popup'] || e.target.closest('button[data-popup]');
const isClickedClose =
e.target.id === this.closeTopicId ||
e.target.closest(`#${this.closeTopicId}`);
if (isClickedClose) {
if (isClickedClose || isClickedPopupOpener) {
this.close();
}
}
@@ -308,9 +316,8 @@ export class Topic {
'roadmap.topic.click',
this.handleRoadmapTopicClick
);
window.addEventListener('click', this.handleOverlayClick);
window.addEventListener('contextmenu', this.rightClickListener);
window.addEventListener('click', this.handleOverlayClick);
window.addEventListener('keydown', (e) => {
if (e.key.toLowerCase() === 'escape') {
this.close();

View File

@@ -1,5 +1,5 @@
---
import Icon from '../Icon.astro';
import Icon from '../AstroIcon.astro';
---
<script src='./topics.js'></script>

View File

@@ -1,6 +1,6 @@
---
import CaptchaFields from './Captcha/CaptchaFields.astro';
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
---
<div class='my-0 px-5 rounded-lg text-left sm:text-center sm:pb-10 pb-8'>

View File

@@ -1,5 +1,5 @@
---
import Icon from './Icon.astro';
import Icon from './AstroIcon.astro';
---
<!-- sticky top-0 -->

1
src/env.d.ts vendored
View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv {
GITHUB_SHA: string;
PUBLIC_API_URL: string;
}
interface ImportMeta {

16
src/hooks/use-keydown.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useEffect, useState } from 'preact/hooks';
export function useKeydown(keyName: string, callback: any) {
useEffect(() => {
const listener = (event: any) => {
if (event.key.toLowerCase() === keyName.toLowerCase()) {
callback();
}
};
window.addEventListener('keydown', listener);
return () => {
window.removeEventListener('keydown', listener);
};
}, []);
}

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'preact/hooks';
import type { ResourceType } from '../lib/resource-progress';
type CallbackType = (data: {
resourceType: ResourceType;
resourceId: string;
topicId: string;
}) => void;
export function useLoadTopic(callback: CallbackType) {
useEffect(() => {
function handleTopicClick(e: any) {
const { resourceType, resourceId, topicId } = e.detail;
callback({
resourceType,
resourceId,
topicId,
});
}
window.addEventListener(`roadmap.topic.click`, handleTopicClick);
window.addEventListener(`best-practice.topic.click`, handleTopicClick);
return () => {
window.removeEventListener(`roadmap.topic.click`, handleTopicClick);
window.removeEventListener(`best-practice.topic.click`, handleTopicClick);
};
}, []);
}

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from 'preact/hooks';
export function useOutsideClick(ref: any, callback: any) {
useEffect(() => {
const listener = (event: any) => {
const isClickedOutside = !ref?.current?.contains(event.target);
if (isClickedOutside) {
callback();
}
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref]);
}

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'preact/hooks';
import type { ResourceType } from '../lib/resource-progress';
type CallbackType = (data: {
resourceType: ResourceType;
resourceId: string;
topicId: string;
}) => void;
export function useToggleTopic(callback: CallbackType) {
useEffect(() => {
function handleToggleTopic(e: any) {
const { resourceType, resourceId, topicId } = e.detail;
callback({
resourceType,
resourceId,
topicId,
});
}
window.addEventListener(`best-practice.topic.toggle`, handleToggleTopic);
return () => {
window.removeEventListener(
`best-practice.topic.toggle`,
handleToggleTopic
);
};
}, []);
}

View File

@@ -1,5 +1,3 @@
<svg viewBox="0 0 14 14" focusable="false" class="h-3 w-3" aria-hidden="true">
<g fill="currentColor">
<polygon points="5.5 11.9993304 14 3.49933039 12.5 2 5.5 8.99933039 1.5 4.9968652 0 6.49933039"></polygon>
</g>
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 9.99933L14 1.49933L12.5 0L5.5 6.99933L1.5 2.99687L0 4.49933L5.5 9.99933Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 208 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 227 B

3
src/icons/dropdown.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 227 B

18
src/icons/error.svg Normal file
View File

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512px" height="512px">
<linearGradient id="wRKXFJsqHCxLE9yyOYHkza" x1="9.858" x2="38.142" y1="9.858" y2="38.142"
gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#f44f5a"/>
<stop offset=".443" stop-color="#ee3d4a"/>
<stop offset="1" stop-color="#e52030"/>
</linearGradient>
<path fill="url(#wRKXFJsqHCxLE9yyOYHkza)"
d="M44,24c0,11.045-8.955,20-20,20S4,35.045,4,24S12.955,4,24,4S44,12.955,44,24z"/>
<path d="M33.192,28.95L28.243,24l4.95-4.95c0.781-0.781,0.781-2.047,0-2.828l-1.414-1.414 c-0.781-0.781-2.047-0.781-2.828,0L24,19.757l-4.95-4.95c-0.781-0.781-2.047-0.781-2.828,0l-1.414,1.414 c-0.781,0.781-0.781,2.047,0,2.828l4.95,4.95l-4.95,4.95c-0.781,0.781-0.781,2.047,0,2.828l1.414,1.414 c0.781,0.781,2.047,0.781,2.828,0l4.95-4.95l4.95,4.95c0.781,0.781,2.047,0.781,2.828,0l1.414-1.414 C33.973,30.997,33.973,29.731,33.192,28.95z"
opacity=".05"/>
<path d="M32.839,29.303L27.536,24l5.303-5.303c0.586-0.586,0.586-1.536,0-2.121l-1.414-1.414 c-0.586-0.586-1.536-0.586-2.121,0L24,20.464l-5.303-5.303c-0.586-0.586-1.536-0.586-2.121,0l-1.414,1.414 c-0.586,0.586-0.586,1.536,0,2.121L20.464,24l-5.303,5.303c-0.586,0.586-0.586,1.536,0,2.121l1.414,1.414 c0.586,0.586,1.536,0.586,2.121,0L24,27.536l5.303,5.303c0.586,0.586,1.536,0.586,2.121,0l1.414-1.414 C33.425,30.839,33.425,29.889,32.839,29.303z"
opacity=".07"/>
<path fill="#fff"
d="M31.071,15.515l1.414,1.414c0.391,0.391,0.391,1.024,0,1.414L18.343,32.485 c-0.391,0.391-1.024,0.391-1.414,0l-1.414-1.414c-0.391-0.391-0.391-1.024,0-1.414l14.142-14.142 C30.047,15.124,30.681,15.124,31.071,15.515z"/>
<path fill="#fff"
d="M32.485,31.071l-1.414,1.414c-0.391,0.391-1.024,0.391-1.414,0L15.515,18.343 c-0.391-0.391-0.391-1.024,0-1.414l1.414-1.414c0.391-0.391,1.024-0.391,1.414,0l14.142,14.142 C32.876,30.047,32.876,30.681,32.485,31.071z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

1
src/icons/github.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" xmlns:v="https://vecta.io/nano"><path fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362l-.08-9.127c-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126l-.08 13.526c0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>

After

Width:  |  Height:  |  Size: 941 B

1
src/icons/google.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-[18px] w-[18px]" viewBox="0 0 90 92" fill="none" xmlns:v="https://vecta.io/nano"><path d="M90 47.1c0-3.1-.3-6.3-.8-9.3H45.9v17.7h24.8c-1 5.7-4.3 10.7-9.2 13.9l14.8 11.5C85 72.8 90 61 90 47.1z" fill="#4280ef"/><path d="M45.9 91.9c12.4 0 22.8-4.1 30.4-11.1L61.5 69.4c-4.1 2.8-9.4 4.4-15.6 4.4-12 0-22.1-8.1-25.8-18.9L4.9 66.6c7.8 15.5 23.6 25.3 41 25.3z" fill="#34a353"/><path d="M20.1 54.8c-1.9-5.7-1.9-11.9 0-17.6L4.9 25.4c-6.5 13-6.5 28.3 0 41.2l15.2-11.8z" fill="#f6b704"/><path d="M45.9 18.3c6.5-.1 12.9 2.4 17.6 6.9L76.6 12C68.3 4.2 57.3 0 45.9.1c-17.4 0-33.2 9.8-41 25.3l15.2 11.8c3.7-10.9 13.8-18.9 25.8-18.9z" fill="#e54335"/></svg>

After

Width:  |  Height:  |  Size: 688 B

View File

@@ -1,6 +1,4 @@
<svg viewBox="0 0 24 24" focusable="false" class="w-3 h-3" aria-hidden="true">
<g fill="currentColor">
<path d="M10.319,4.936a7.239,7.239,0,0,1,7.1,2.252,1.25,1.25,0,1,0,1.872-1.657A9.737,9.737,0,0,0,9.743,2.5,10.269,10.269,0,0,0,2.378,9.61a.249.249,0,0,1-.271.178l-1.033-.13A.491.491,0,0,0,.6,9.877a.5.5,0,0,0-.019.526l2.476,4.342a.5.5,0,0,0,.373.248.43.43,0,0,0,.062,0,.5.5,0,0,0,.359-.152l3.477-3.593a.5.5,0,0,0-.3-.844L5.15,10.172a.25.25,0,0,1-.2-.333A7.7,7.7,0,0,1,10.319,4.936Z"></path>
<path d="M23.406,14.1a.5.5,0,0,0,.015-.526l-2.5-4.329A.5.5,0,0,0,20.546,9a.489.489,0,0,0-.421.151l-3.456,3.614a.5.5,0,0,0,.3.842l1.848.221a.249.249,0,0,1,.183.117.253.253,0,0,1,.023.216,7.688,7.688,0,0,1-5.369,4.9,7.243,7.243,0,0,1-7.1-2.253,1.25,1.25,0,1,0-1.872,1.656,9.74,9.74,0,0,0,9.549,3.03,10.261,10.261,0,0,0,7.369-7.12.251.251,0,0,1,.27-.179l1.058.127a.422.422,0,0,0,.06,0A.5.5,0,0,0,23.406,14.1Z"></path>
</g>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.3193 4.93528C11.5957 4.63203 12.9306 4.68137 14.1811 5.07803C15.4317 5.47469 16.551 6.20375 17.4193 7.18728C17.639 7.43552 17.9483 7.58631 18.2793 7.60647C18.6102 7.62663 18.9355 7.51451 19.1838 7.29478C19.432 7.07505 19.5828 6.7657 19.603 6.43479C19.6231 6.10389 19.511 5.77852 19.2913 5.53028C18.1237 4.20738 16.6187 3.22659 14.9369 2.69273C13.2552 2.15887 11.46 2.092 9.74327 2.49928C8.00102 2.9367 6.404 3.82349 5.11164 5.07111C3.81927 6.31873 2.87678 7.88352 2.37827 9.60928C2.36179 9.66642 2.32541 9.71578 2.27571 9.74843C2.226 9.78108 2.16625 9.79486 2.10727 9.78728L1.07427 9.65728C0.982658 9.64551 0.889587 9.65981 0.805742 9.69855C0.721897 9.73729 0.650678 9.79889 0.600266 9.87628C0.548506 9.95352 0.519307 10.0437 0.515951 10.1366C0.512595 10.2295 0.535213 10.3215 0.581266 10.4023L3.05727 14.7443C3.09587 14.8118 3.14969 14.8693 3.21444 14.9124C3.27919 14.9554 3.35309 14.9828 3.43027 14.9923C3.45091 14.9938 3.47163 14.9938 3.49227 14.9923C3.55924 14.9923 3.62552 14.9788 3.68719 14.9527C3.74886 14.9266 3.80466 14.8884 3.85127 14.8403L7.32827 11.2473C7.39298 11.1803 7.43773 11.0967 7.45745 11.0057C7.47718 10.9147 7.47111 10.82 7.43993 10.7323C7.40875 10.6445 7.35369 10.5673 7.28096 10.5091C7.20823 10.451 7.12071 10.4144 7.02827 10.4033L5.15027 10.1713C5.11341 10.1661 5.07817 10.1527 5.04714 10.1322C5.01611 10.1116 4.99006 10.0844 4.97089 10.0525C4.95173 10.0205 4.93993 9.98475 4.93636 9.9477C4.93279 9.91065 4.93754 9.87326 4.95027 9.83828C5.37211 8.64203 6.08295 7.56852 7.01961 6.71315C7.95627 5.85779 9.08973 5.24707 10.3193 4.93528Z" fill="white"/>
<path d="M23.4056 14.1003C23.4568 14.0226 23.4853 13.9323 23.4879 13.8394C23.4905 13.7465 23.4672 13.6547 23.4206 13.5743L20.9206 9.24526C20.8815 9.17807 20.8272 9.12095 20.7621 9.07841C20.697 9.03588 20.6229 9.00912 20.5456 9.00026C20.4685 8.99013 20.3901 8.99854 20.3168 9.02481C20.2436 9.05107 20.1777 9.09442 20.1246 9.15126L16.6686 12.7653C16.6045 12.8323 16.5602 12.9158 16.5408 13.0065C16.5214 13.0972 16.5277 13.1915 16.5588 13.2788C16.5899 13.3662 16.6447 13.4432 16.7171 13.5012C16.7895 13.5592 16.8766 13.5959 16.9686 13.6073L18.8166 13.8283C18.854 13.8327 18.8898 13.8455 18.9215 13.8658C18.9532 13.886 18.9799 13.9132 18.9996 13.9453C19.0192 13.9773 19.0315 14.0133 19.0355 14.0507C19.0394 14.088 19.0351 14.1258 19.0226 14.1613C18.6013 15.3575 17.8906 16.4309 16.9538 17.2859C16.017 18.1408 14.8833 18.7507 13.6536 19.0613C12.3771 19.3639 11.0423 19.3142 9.79178 18.9174C8.54129 18.5206 7.42206 17.7916 6.55361 16.8083C6.44613 16.681 6.31431 16.5765 6.16589 16.5009C6.01746 16.4253 5.85543 16.3802 5.68931 16.3681C5.52318 16.356 5.35632 16.3773 5.19852 16.4306C5.04072 16.4839 4.89517 16.5682 4.77042 16.6786C4.64566 16.7889 4.54422 16.9231 4.47205 17.0732C4.39989 17.2233 4.35845 17.3864 4.35018 17.5527C4.3419 17.7191 4.36696 17.8854 4.42388 18.0419C4.48079 18.1985 4.56842 18.3421 4.68161 18.4643C5.84954 19.7869 7.35483 20.7674 9.03668 21.3011C10.7185 21.8347 12.5138 21.9015 14.2306 21.4943C15.9751 21.0573 17.5742 20.1696 18.8675 18.9199C20.1608 17.6703 21.103 16.1027 21.5996 14.3743C21.6162 14.3173 21.6524 14.2681 21.7019 14.2354C21.7513 14.2026 21.8107 14.1884 21.8696 14.1953L22.9276 14.3223C22.9476 14.3237 22.9676 14.3237 22.9876 14.3223C23.0702 14.3227 23.1516 14.3026 23.2245 14.2638C23.2975 14.2251 23.3597 14.1689 23.4056 14.1003Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,4 +1,4 @@
<svg class='h-6 w-6 sm:w-12 sm:h-12 text-gray-200 animate-spin fill-blue-600' 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" fill="currentColor"/>
<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" fill="currentFill"/>
</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" fill="#e5e7eb"/>
<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" fill="#2463eb" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="512px" height="512px"><path fill="#f79219" d="M222.58,114.782c0-8.69-3.979-16.901-10.8-22.286l-69.526-54.889c-8.357-6.598-20.15-6.598-28.508,0 L44.22,92.496c-6.82,5.385-10.8,13.596-10.8,22.286v12.732H222.58V114.782z"/><path fill="#ffa91a" d="M213.336,223.341H42.664c-5.105,0-9.244-4.138-9.244-9.244V113.116c0-5.105,4.138-9.244,9.244-9.244 h170.672c5.105,0,9.244,4.139,9.244,9.244v100.981C222.58,219.203,218.441,223.341,213.336,223.341z"/><path fill="#f79219" d="M213.336,103.872h-0.756v100.225c0,5.105-4.138,9.244-9.244,9.244H33.42v0.756 c0,5.105,4.138,9.244,9.244,9.244h170.672c5.105,0,9.244-4.138,9.244-9.244V113.116 C222.58,108.011,218.441,103.872,213.336,103.872z"/><path fill="#ef7816" d="M213.336,103.872H42.664c-4.488,0-8.229,3.199-9.067,7.441l79.417,62.697 c8.787,6.937,21.186,6.937,29.973,0l79.417-62.698C221.564,107.071,217.824,103.872,213.336,103.872z"/><path fill="#f1f2f2" d="M203.33,73.49v52.88l-60.34,47.64c-8.789,6.939-21.191,6.939-29.98,0l-60.34-47.64V73.49 c0-4.418,3.582-8,8-8h134.66C199.748,65.49,203.33,69.072,203.33,73.49z"/><g><path fill="#fff" d="M58.67,125.46c-1.101,0-2-0.9-2-2V73.49c0-2.2,1.8-4,4-4h106.89c1.101,0,1.99,0.9,1.99,2s-0.89,2-1.99,2 H60.67v49.97C60.67,124.56,59.77,125.46,58.67,125.46z M175.55,73.49c-1.1,0-2-0.9-2-2s0.9-2,2-2c1.11,0,2,0.9,2,2 S176.66,73.49,175.55,73.49z"/></g><g><path fill="#e6e7e8" d="M195.33,65.49h-2v50.88l-60.34,47.64c-8.789,6.939-21.191,6.939-29.98,0l-50.34-39.745v2.105l60.34,47.64 c8.789,6.939,21.191,6.939,29.98,0l60.34-47.64V73.49C203.33,69.072,199.748,65.49,195.33,65.49z"/></g><g><path fill="#d1d3d4" d="M197.9,65.92c0.274,0.808,0.43,1.67,0.43,2.57v52.88l-60.34,47.64c-8.789,6.939-21.191,6.939-29.98,0 l-55.34-43.692v1.052l60.34,47.64c8.789,6.939,21.191,6.939,29.98,0l60.34-47.64V73.49 C203.33,69.972,201.056,66.991,197.9,65.92z"/></g><g><path fill="#d1d3d4" d="M109.036,99.997H80.422c-1.431,0-2.591-1.16-2.591-2.591v0c0-1.431,1.16-2.591,2.591-2.591h28.614 c1.431,0,2.591,1.16,2.591,2.591v0C111.627,98.836,110.467,99.997,109.036,99.997z"/><path fill="#d1d3d4" d="M175.578,124.03H80.422c-1.431,0-2.591-1.16-2.591-2.591v0c0-1.431,1.16-2.591,2.591-2.591h95.156 c1.431,0,2.591,1.16,2.591,2.591v0C178.169,122.87,177.009,124.03,175.578,124.03z"/><path fill="#d1d3d4" d="M175.578,138.881H80.422c-1.431,0-2.591-1.16-2.591-2.591l0,0c0-1.431,1.16-2.591,2.591-2.591h95.156 c1.431,0,2.591,1.16,2.591,2.591l0,0C178.169,137.721,177.009,138.881,175.578,138.881z"/><polygon fill="#d1d3d4" points="156.425,163.403 99.575,163.403 106.139,168.585 149.861,168.585"/></g><g><polygon fill="#d1d3d4" points="175.236,148.551 80.764,148.551 87.328,153.733 168.672,153.733"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -1,13 +1,14 @@
---
import '../styles/global.css';
import Navigation from '../components/Navigation.astro';
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
import Analytics from '../components/Analytics/Analytics.astro';
import Authenticator from '../components/Authenticator/Authenticator.astro';
import Footer from '../components/Footer.astro';
import { PageProgress } from '../components/PageProgress';
import Navigation from '../components/Navigation/Navigation.astro';
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
import type { SponsorType } from '../components/Sponsor/Sponsor.astro';
import Sponsor from '../components/Sponsor/Sponsor.astro';
import YouTubeBanner from '../components/YouTubeBanner.astro';
import { siteConfig } from '../lib/config';
import Analytics from '../components/Analytics/Analytics.astro';
import '../styles/global.css';
export interface Props {
title: string;
@@ -38,7 +39,9 @@ const currentPageAbsoluteUrl = `https://roadmap.sh${permalink}`;
const canonicalUrl = givenCanonical || currentPageAbsoluteUrl;
const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${import.meta.env.GITHUB_SHA}`;
const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${
import.meta.env.GITHUB_SHA
}`;
---
<!DOCTYPE html>
@@ -51,7 +54,11 @@ const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${i
<meta name='description' content={description} />
<meta name='author' content='Kamran Ahmed' />
<meta name='keywords' content={keywords.join(', ')} />
{redirectUrl && <meta http-equiv='refresh' content={`1;url=${redirectUrl}`} />}
{
redirectUrl && (
<meta http-equiv='refresh' content={`1;url=${redirectUrl}`} />
)
}
{noIndex && <meta name='robots' content='noindex' />}
<meta
name='viewport'
@@ -76,23 +83,48 @@ const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${i
<meta name='mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='apple-mobile-web-app-status-bar-style' content='black-translucent' />
<meta
name='apple-mobile-web-app-status-bar-style'
content='black-translucent'
/>
<meta name='apple-mobile-web-app-title' content='roadmap.sh' />
<meta name='application-name' content='roadmap.sh' />
<link rel='apple-touch-icon' sizes='180x180' href='/manifest/apple-touch-icon.png' />
<link
rel='apple-touch-icon'
sizes='180x180'
href='/manifest/apple-touch-icon.png'
/>
<meta name='msapplication-TileColor' content='#101010' />
<meta name='theme-color' content='#848a9a' />
<link rel='manifest' href='/manifest/manifest.json' />
<link rel='icon' type='image/png' sizes='32x32' href='/manifest/icon32.png' />
<link rel='icon' type='image/png' sizes='16x16' href='/manifest/icon16.png' />
<link rel='shortcut icon' href='/manifest/favicon.ico' type='image/x-icon' />
<link
rel='icon'
type='image/png'
sizes='32x32'
href='/manifest/icon32.png'
/>
<link
rel='icon'
type='image/png'
sizes='16x16'
href='/manifest/icon16.png'
/>
<link
rel='shortcut icon'
href='/manifest/favicon.ico'
type='image/x-icon'
/>
<link rel='icon' href='/manifest/favicon.ico' type='image/x-icon' />
<slot name='after-header' />
{jsonLd.length > 0 && <script type='application/ld+json' set:html={JSON.stringify(jsonLd)} />}
{
jsonLd.length > 0 && (
<script type='application/ld+json' set:html={JSON.stringify(jsonLd)} />
)
}
</head>
<body>
<slot name='page-header'>
@@ -105,8 +137,12 @@ const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${i
<OpenSourceBanner />
<Footer />
{sponsor && <Sponsor sponsor={sponsor} />}
<slot name='after-footer' />
<Analytics />
</slot>
<Analytics />
<Authenticator />
<PageProgress client:load />
<slot name='after-footer' />
</body>
</html>

View File

@@ -0,0 +1,12 @@
---
import BaseLayout,{ Props as BaseLayoutProps } from './BaseLayout.astro';
export interface Props extends BaseLayoutProps {}
const props = Astro.props;
---
<BaseLayout {...props}>
<slot />
<div slot='page-footer'></div>
</BaseLayout>

153
src/lib/http.ts Normal file
View File

@@ -0,0 +1,153 @@
import Cookies from 'js-cookie';
import fp from '@fingerprintjs/fingerprintjs';
import { TOKEN_COOKIE_NAME } from './jwt';
type HttpOptionsType = RequestInit | { headers: Record<string, any> };
type AppResponse = Record<string, any>;
type FetchError = {
status: number;
message: string;
};
type AppError = {
status: number;
message: string;
errors?: { message: string; location: string }[];
};
type ApiReturn<ResponseType, ErrorType> = {
response?: ResponseType;
error?: ErrorType | FetchError;
};
/**
* Wrapper around fetch to make it easy to handle errors
*
* @param url
* @param options
*/
export async function httpCall<
ResponseType = AppResponse,
ErrorType = AppError
>(
url: string,
options?: HttpOptionsType
): Promise<ApiReturn<ResponseType, ErrorType>> {
try {
const fingerprintPromise = await fp.load({ monitoring: false });
const fingerprint = await fingerprintPromise.get();
const response = await fetch(url, {
credentials: 'include',
...options,
headers: new Headers({
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
'fp': fingerprint.visitorId,
...(options?.headers ?? {}),
}),
});
// @ts-ignore
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
const data = doesAcceptHtml ? await response.text() : await response.json();
if (response.ok) {
return {
response: data as ResponseType,
error: undefined,
};
}
// Logout user if token is invalid
if (data.status === 401) {
Cookies.remove(TOKEN_COOKIE_NAME);
window.location.reload();
}
return {
response: undefined,
error: data as ErrorType,
};
} catch (error: any) {
return {
response: undefined,
error: {
status: 0,
message: error.message,
},
};
}
}
export async function httpPost<
ResponseType = AppResponse,
ErrorType = AppError
>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType
): Promise<ApiReturn<ResponseType, ErrorType>> {
return httpCall<ResponseType, ErrorType>(url, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
}
export async function httpGet<ResponseType = AppResponse, ErrorType = AppError>(
url: string,
queryParams?: Record<string, any>,
options?: HttpOptionsType
): Promise<ApiReturn<ResponseType, ErrorType>> {
const searchParams = new URLSearchParams(queryParams).toString();
const queryUrl = searchParams ? `${url}?${searchParams}` : url;
return httpCall<ResponseType, ErrorType>(queryUrl, {
credentials: 'include',
method: 'GET',
...options,
});
}
export async function httpPatch<
ResponseType = AppResponse,
ErrorType = AppError
>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType
): Promise<ApiReturn<ResponseType, ErrorType>> {
return httpCall<ResponseType, ErrorType>(url, {
...options,
method: 'PATCH',
body: JSON.stringify(body),
});
}
export async function httpPut<ResponseType = AppResponse, ErrorType = AppError>(
url: string,
body: Record<string, any>,
options?: HttpOptionsType
): Promise<ApiReturn<ResponseType, ErrorType>> {
return httpCall<ResponseType, ErrorType>(url, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}
export async function httpDelete<
ResponseType = AppResponse,
ErrorType = AppError
>(
url: string,
options?: HttpOptionsType
): Promise<ApiReturn<ResponseType, ErrorType>> {
return httpCall<ResponseType, ErrorType>(url, {
...options,
method: 'DELETE',
});
}

22
src/lib/jwt.ts Normal file
View File

@@ -0,0 +1,22 @@
import * as jose from 'jose';
import Cookies from 'js-cookie';
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__';
export type TokenPayload = {
id: string;
email: string;
name: string;
};
export function decodeToken(token: string): TokenPayload {
const claims = jose.decodeJwt(token);
return claims as TokenPayload;
}
export function isLoggedIn() {
const token = Cookies.get(TOKEN_COOKIE_NAME);
return !!token;
}

View File

@@ -0,0 +1,163 @@
import { httpGet, httpPatch } from './http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from './jwt';
import Element = astroHTML.JSX.Element;
export type ResourceType = 'roadmap' | 'best-practice';
type TopicMeta = {
topicId: string;
resourceType: ResourceType;
resourceId: string;
};
export async function isTopicDone(topic: TopicMeta): Promise<boolean> {
const { topicId, resourceType, resourceId } = topic;
const doneItems = await getResourceProgress(resourceType, resourceId);
if (!doneItems) {
return false;
}
return doneItems.includes(topicId);
}
export async function toggleMarkTopicDone(
topic: TopicMeta,
isDone: boolean
): Promise<boolean> {
const { topicId, resourceType, resourceId } = topic;
const { response, error } = await httpPatch<{ done: string[] }>(
`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`,
{
topicId,
resourceType,
resourceId,
isDone,
}
);
if (error || !response?.done) {
throw new Error(error?.message || 'Something went wrong');
}
setResourceProgress(resourceType, resourceId, response.done);
return isDone;
}
export async function getResourceProgress(
resourceType: 'roadmap' | 'best-practice',
resourceId: string
): Promise<string[]> {
// No need to load progress if user is not logged in
if (!Cookies.get(TOKEN_COOKIE_NAME)) {
return [];
}
const progressKey = `${resourceType}-${resourceId}-progress`;
const rawProgress = localStorage.getItem(progressKey);
const progress = JSON.parse(rawProgress || 'null');
const progressTimestamp = progress?.timestamp;
const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10);
const isProgressExpired = diff > 15 * 60 * 1000; // 15 minutes
if (!progress || isProgressExpired) {
return loadFreshProgress(resourceType, resourceId);
}
return progress.done;
}
async function loadFreshProgress(
resourceType: ResourceType,
resourceId: string
) {
const { response, error } = await httpGet<{ done: string[] }>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`,
{
resourceType,
resourceId,
}
);
if (error) {
console.error(error);
return [];
}
if (!response?.done) {
return [];
}
setResourceProgress(resourceType, resourceId, response.done);
return response.done;
}
export function setResourceProgress(
resourceType: 'roadmap' | 'best-practice',
resourceId: string,
done: string[]
): void {
localStorage.setItem(
`${resourceType}-${resourceId}-progress`,
JSON.stringify({
done,
timestamp: new Date().getTime(),
})
);
}
export function renderTopicProgress(topicId: string, isDone: boolean) {
const matchingElements: Element[] = [];
// Elements having sort order in the beginning of the group id
document
.querySelectorAll(`[data-group-id$="-${topicId}"]`)
.forEach((element: unknown) => {
const foundGroupId =
(element as HTMLOrSVGElement)?.dataset?.groupId || '';
const validGroupRegex = new RegExp(`^\\d+-${topicId}$`);
if (validGroupRegex.test(foundGroupId)) {
matchingElements.push(element);
}
});
// Elements with exact match of the topic id
document
.querySelectorAll(`[data-group-id="${topicId}"]`)
.forEach((element) => {
matchingElements.push(element);
});
// Matching "check:XXXX" box of the topic
document
.querySelectorAll(`[data-group-id="check:${topicId}"]`)
.forEach((element) => {
matchingElements.push(element);
});
matchingElements.forEach((element) => {
if (isDone) {
element.classList.add('done');
} else {
element.classList.remove('done');
}
});
}
export async function renderResourceProgress(
resourceType: ResourceType,
resourceId: string
) {
const progress = await getResourceProgress(resourceType, resourceId);
progress.forEach((topicId) => {
renderTopicProgress(topicId, true);
});
}

View File

@@ -1,5 +1,5 @@
import type { MarkdownFileType } from './file';
import type { RoadmapFrontmatter } from './roadmap';
import type {MarkdownFileType} from './file';
import type {RoadmapFrontmatter} from './roadmap';
// Generates URL from the topic file path e.g.
// -> /src/data/roadmaps/vue/content/102-ecosystem/102-ssr/101-nuxt-js.md
@@ -47,17 +47,15 @@ function generateBreadcrumbs(
}
}
const breadcrumbs = breadcrumbUrls.map((breadCrumbUrl): BreadcrumbItem => {
return breadcrumbUrls.map((breadCrumbUrl): BreadcrumbItem => {
const topicFile = topicFiles[breadCrumbUrl];
const topicFileContent = topicFile?.file;
const firstHeading = topicFileContent?.getHeadings()?.[0];
return { title: firstHeading?.text, url: breadCrumbUrl };
return {title: firstHeading?.text, url: breadCrumbUrl};
});
return breadcrumbs;
}
export type BreadcrumbItem = {
@@ -123,7 +121,7 @@ export async function getRoadmapTopicFiles(): Promise<
const roadmapUrl = `/${roadmapId}`;
// Breadcrumbs for the file
const breadcrumbs: BreadcrumbItem[] = [
mapping[topicUrl].breadcrumbs = [
{
title: 'Roadmaps',
url: '/roadmaps',
@@ -138,8 +136,6 @@ export async function getRoadmapTopicFiles(): Promise<
},
...generateBreadcrumbs(topicUrl, mapping),
];
mapping[topicUrl].breadcrumbs = breadcrumbs;
});
return mapping;

View File

@@ -1,5 +1,5 @@
---
import Icon from '../components/Icon.astro';
import Icon from '../components/AstroIcon.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { getRoadmapIds } from '../lib/roadmap';

View File

@@ -1,15 +1,17 @@
---
import CaptchaScripts from '../../components/Captcha/CaptchaScripts.astro';
import FAQs from '../../components/FAQs/FAQs.astro';
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
import MarkdownFile from '../../components/MarkdownFile.astro';
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
import RoadmapHeader from '../../components/RoadmapHeader.astro';
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
import TopicOverlay from '../../components/TopicOverlay/TopicOverlay.astro';
import UpcomingForm from '../../components/UpcomingForm.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { generateArticleSchema, generateFAQSchema } from '../../lib/jsonld-schema';
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
import {
generateArticleSchema,
generateFAQSchema,
} from '../../lib/jsonld-schema';
import { getRoadmapIds, RoadmapFrontmatter } from '../../lib/roadmap';
export async function getStaticPaths() {
@@ -25,8 +27,12 @@ interface Params extends Record<string, string | undefined> {
}
const { roadmapId } = Astro.params as Params;
const roadmapFile = await import(`../../data/roadmaps/${roadmapId}/${roadmapId}.md`);
const { faqs: roadmapFAQs = [] } = await import(`../../data/roadmaps/${roadmapId}/faqs.astro`);
const roadmapFile = await import(
`../../data/roadmaps/${roadmapId}/${roadmapId}.md`
);
const { faqs: roadmapFAQs = [] } = await import(
`../../data/roadmaps/${roadmapId}/faqs.astro`
);
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
let jsonLdSchema = [];
@@ -62,7 +68,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
jsonLd={jsonLdSchema}
>
<!-- Preload the font being used in the renderer -->
<link rel='preload' href='/fonts/balsamiq.woff2' as='font' type='font/woff2' crossorigin slot='after-header' />
<link
rel='preload'
href='/fonts/balsamiq.woff2'
as='font'
type='font/woff2'
crossorigin
slot='after-header'
/>
<RoadmapHeader
title={roadmapData.title}
@@ -77,9 +90,12 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
<div class='bg-gray-50 pt-4 sm:pt-12'>
{
!roadmapData.isUpcoming && roadmapData.jsonUrl && (
<div class='max-w-[1000px] container relative'>
<ShareIcons description={roadmapData.briefDescription} pageUrl={`https://roadmap.sh/${roadmapId}`} />
<TopicOverlay contentContributionLink={contentContributionLink} />
<div class='container relative max-w-[1000px]'>
<ShareIcons
description={roadmapData.briefDescription}
pageUrl={`https://roadmap.sh/${roadmapId}`}
/>
<TopicDetail client:load />
<FrameRenderer
resourceType={'roadmap'}
@@ -93,7 +109,7 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
{
!roadmapData.isUpcoming && !roadmapData.jsonUrl && (
<div class='mt-0 sm:-mt-6 pb-14'>
<div class='mt-0 pb-14 sm:-mt-6'>
<MarkdownFile>
<roadmapFile.Content />
</MarkdownFile>
@@ -105,7 +121,5 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
<FAQs faqs={roadmapFAQs} />
<RelatedRoadmaps roadmap={roadmapData} />
<CaptchaScripts slot='after-footer' />
</div>
</BaseLayout>

View File

@@ -1,13 +1,15 @@
---
import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
import BestPracticeHeader from '../../../components/BestPracticeHeader.astro';
import CaptchaScripts from '../../../components/Captcha/CaptchaScripts.astro';
import FrameRenderer from '../../../components/FrameRenderer/FrameRenderer.astro';
import MarkdownFile from '../../../components/MarkdownFile.astro';
import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro';
import TopicOverlay from '../../../components/TopicOverlay/TopicOverlay.astro';
import UpcomingForm from '../../../components/UpcomingForm.astro';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import { BestPracticeFrontmatter, getBestPracticeIds } from '../../../lib/best-pratice';
import {
BestPracticeFrontmatter,
getBestPracticeIds,
} from '../../../lib/best-pratice';
import { generateArticleSchema } from '../../../lib/jsonld-schema';
export async function getStaticPaths() {
@@ -23,8 +25,11 @@ interface Params extends Record<string, string | undefined> {
}
const { bestPracticeId } = Astro.params as Params;
const bestPracticeFile = await import(`../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`);
const bestPracticeData = bestPracticeFile.frontmatter as BestPracticeFrontmatter;
const bestPracticeFile = await import(
`../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`
);
const bestPracticeData =
bestPracticeFile.frontmatter as BestPracticeFrontmatter;
let jsonLdSchema = [];
@@ -55,7 +60,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
jsonLd={jsonLdSchema}
>
<!-- Preload the font being used in the renderer -->
<link rel='preload' href='/fonts/balsamiq.woff2' as='font' type='font/woff2' crossorigin slot='after-header' />
<link
rel='preload'
href='/fonts/balsamiq.woff2'
as='font'
type='font/woff2'
crossorigin
slot='after-header'
/>
<BestPracticeHeader
title={bestPracticeData.title}
@@ -67,12 +79,13 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
<div class='bg-gray-50 py-4 sm:py-12'>
{
!bestPracticeData.isUpcoming && bestPracticeData.jsonUrl && (
<div class='max-w-[1000px] container relative'>
<div class='container relative max-w-[1000px]'>
<ShareIcons
description={bestPracticeData.briefDescription}
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
/>
<TopicOverlay contentContributionLink={contentContributionLink} />
<TopicDetail client:load />
<FrameRenderer
resourceType={'best-practice'}
@@ -94,5 +107,4 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
</div>
{bestPracticeData.isUpcoming && <UpcomingForm />}
<CaptchaScripts slot='after-footer' />
</BaseLayout>

View File

@@ -0,0 +1,32 @@
---
import { ForgotPasswordForm } from '../components/AuthenticationFlow/ForgotPasswordForm';
import SettingLayout from '../layouts/SettingLayout.astro';
---
<SettingLayout title='Forgot Password'>
<div class='container'>
<div
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'
>
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>
Forgot Password?
</h1>
<p class='mb-3 text-base leading-6 text-gray-600'>
Enter your email address below and we will send you a link to reset
your password.
</p>
</div>
<ForgotPasswordForm client:load />
<div class='mt-6 text-center text-sm'>
Don't have an account? <a
href='/signup'
class='font-medium text-blue-600 transition duration-150 ease-in-out hover:text-blue-500'
>Sign up</a
>
</div>
</div>
</div>
</SettingLayout>

41
src/pages/login.astro Normal file
View File

@@ -0,0 +1,41 @@
---
import Divider from '../components/AuthenticationFlow/Divider.astro';
import { GitHubButton } from '../components/AuthenticationFlow/GitHubButton';
import { GoogleButton } from '../components/AuthenticationFlow/GoogleButton';
import EmailLoginForm from '../components/AuthenticationFlow/EmailLoginForm';
import SettingLayout from '../layouts/SettingLayout.astro';
---
<SettingLayout
title='Login - roadmap.sh'
description='Register yourself to receive occasional emails about new roadmaps, updates, guides and videos'
permalink={'/signup'}
noIndex={true}
>
<div class='container'>
<div class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'>
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>Login</h1>
<p class='text-base text-gray-600 leading-6 mb-3'>
Welcome back! Let's take you to your account.
</p>
</div>
<div class='flex w-full flex-col gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
</div>
<Divider />
<EmailLoginForm client:load />
<div class='mt-6 text-center text-sm text-slate-600'>
Don't have an account?{' '}
<a href='/signup' class='font-medium text-blue-700 hover:text-blue-600'>
Sign up
</a>
</div>
</div>
</div>
</SettingLayout>

View File

@@ -1,43 +0,0 @@
---
layout: ../layouts/MarkdownLayout.astro
title: Roadmap PDFs - roadmap.sh
noIndex: true
---
# Download Roadmap PDFs
Here is the list of PDF links for each of the roadmaps.
- **Frontend Roadmap** - [Roadmap Link](https://roadmap.sh/frontend) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/frontend.pdf)
- **Backend Roadmap** - [Roadmap Link](https://roadmap.sh/backend) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/backend.pdf)
- **DevOps Roadmap** - [Roadmap Link](https://roadmap.sh/devops) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/devops.pdf)
- **Computer Science Roadmap** - [Roadmap Link](https://roadmap.sh/computer-science) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/computer-science.pdf)
- **QA Roadmap** - [Roadmap Link](https://roadmap.sh/qa) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/qa.pdf)
- **ASP.NET Core Roadmap** - [Roadmap Link](https://roadmap.sh/aspnet-core) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/aspnet-core.pdf)
- **Flutter Roadmap** - [Roadmap Link](https://roadmap.sh/flutter) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/flutter.pdf)
- **Go Roadmap** - [Roadmap Link](https://roadmap.sh/golang) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/golang.pdf)
- **Software Architect Roadmap** - [Roadmap Link](https://roadmap.sh/software-architect) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/software-architect.pdf)
- **Software Design and Architecture Roadmap** - [Roadmap Link](https://roadmap.sh/software-design-architecture) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/software-design-architecture.pdf)
- **JavaScript Roadmap** - [Roadmap Link](https://roadmap.sh/javascript) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/javascript.pdf)
- **Node.js Roadmap** - [Roadmap Link](https://roadmap.sh/nodejs) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/nodejs.pdf)
- **TypeScript Roadmap** - [Roadmap Link](https://roadmap.sh/typescript) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/typescript.pdf)
- **GraphQL Roadmap** - [Roadmap Link](https://roadmap.sh/graphql) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/graphql.pdf)
- **Angular Roadmap** - [Roadmap Link](https://roadmap.sh/angular) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/angular.pdf)
- **React Roadmap** - [Roadmap Link](https://roadmap.sh/react) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/react.pdf)
- **Vue Roadmap** - [Roadmap Link](https://roadmap.sh/vue) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/vue.pdf)
- **Design System Roadmap** - [Roadmap Link](https://roadmap.sh/design-system) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/design-system.pdf)
- **Blockchain Roadmap** - [Roadmap Link](https://roadmap.sh/blockchain) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/blockchain.pdf)
- **Java Roadmap** - [Roadmap Link](https://roadmap.sh/java) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/java.pdf)
- **Spring Boot Roadmap** - [Roadmap Link](https://roadmap.sh/spring-boot) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/spring-boot.pdf)
- **Python Roadmap** - [Roadmap Link](https://roadmap.sh/python) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/python.pdf)
- **System Design** - [Roadmap Link](https://roadmap.sh/system-design) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/system-design.pdf)
- **Kubernetes** - [Roadmap Link](https://roadmap.sh/kubernetes) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/kubernetes.pdf)
- **Cyber Security** - [Roadmap Link](https://roadmap.sh/cyber-security) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/cyber-security.pdf)
- **MongoDB** - [Roadmap Link](https://roadmap.sh/mongodb) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/mongodb.pdf)
- **UX Design** - [Roadmap Link](https://roadmap.sh/ux-design) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/ux-design.pdf)
Here is the list of PDF links for each of the best practices:
- **Frontend Performance** - [Best Practices Link](https://roadmap.sh/best-practices/frontend-performance) / [PDF Link](https://roadmap.sh/pdfs/best-practices/frontend-performance.pdf)
- **API Security** - [Best Practices Link](https://roadmap.sh/best-practices/api-security) / [PDF Link](https://roadmap.sh/pdfs/best-practices/api-security.pdf)
- **Amazon Web Services (AWS)** - [Best Practices Link](https://roadmap.sh/best-practices/aws) / [PDF Link](https://roadmap.sh/pdfs/best-practices/aws.pdf)

View File

@@ -0,0 +1,23 @@
---
import ResetPasswordForm from '../components/AuthenticationFlow/ResetPasswordForm';
import SettingLayout from '../layouts/SettingLayout.astro';
---
<SettingLayout title='Reset Password'>
<div class='container'>
<div
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'
>
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>
Reset Password
</h1>
<p class='mb-3 text-base leading-6 text-gray-600'>
Enter and confirm your new password below.
</p>
</div>
<ResetPasswordForm client:load />
</div>
</div>
</SettingLayout>

View File

@@ -0,0 +1,11 @@
---
import UpdatePasswordForm from '../../components/Setting/UpdatePasswordForm';
import SettingSidebar from '../../components/Setting/SettingSidebar.astro';
import SettingLayout from '../../layouts/SettingLayout.astro';
---
<SettingLayout title='Change Password' description=''>
<SettingSidebar pageUrl='change-password' name='Change Password'>
<UpdatePasswordForm client:load />
</SettingSidebar>
</SettingLayout>

View File

@@ -0,0 +1,11 @@
---
import SettingSidebar from '../../components/Setting/SettingSidebar.astro';
import { UpdateProfileForm } from '../../components/Setting/UpdateProfileForm';
import SettingLayout from '../../layouts/SettingLayout.astro';
---
<SettingLayout title='Update Profile'>
<SettingSidebar pageUrl='profile' name='Profile'>
<UpdateProfileForm client:load />
</SettingSidebar>
</SettingLayout>

View File

@@ -1,63 +1,49 @@
---
import CaptchaFields from '../components/Captcha/CaptchaFields.astro';
import CaptchaScripts from '../components/Captcha/CaptchaScripts.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import Divider from '../components/AuthenticationFlow/Divider.astro';
import GoogleLogin from '../components/Login/GoogleLogin.astro';
import EmailSignupForm from '../components/AuthenticationFlow/EmailSignupForm';
import SettingLayout from '../layouts/SettingLayout.astro';
import { GitHubButton } from '../components/AuthenticationFlow/GitHubButton';
import { GoogleButton } from '../components/AuthenticationFlow/GoogleButton';
---
<BaseLayout
<SettingLayout
title='Signup - roadmap.sh'
description='Register yourself to receive occasional emails about new roadmaps, updates, guides and videos'
description='Create an account to track your progress, showcase your skillset'
permalink={'/signup'}
noIndex={true}
>
<div class='container'>
<div
class='py-12 sm:py-0 sm:min-h-[550px] sm:max-w-[400px] mx-auto flex items-start sm:items-center flex-col justify-start sm:justify-center'
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'
>
<div class='mb-2 sm:mb-5 text-left sm:text-center'>
<h1 class='text-3xl sm:text-5xl font-semibold mb-2 sm:mb-4'>Signup</h1>
<p class='hidden sm:block text-md text-gray-600'>
Register yourself to receive occasional emails about new roadmaps, updates, guides and videos
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>Sign Up</h1>
<p class='mb-3 hidden text-base leading-6 text-gray-600 sm:block'>
Create an account to track your progress, showcase your skill-set and
be a part of the community.
</p>
<p class='text-sm block sm:hidden text-gray-600'>
Register yourself for occasional updates about roadmaps, guides and videos.
<p class='mb-3 block text-sm text-gray-600 sm:hidden'>
Create an account to track your progress, showcase your skill-set and
be a part of the community. videos.
</p>
</div>
<form
action='https://news.roadmap.sh/subscribe'
method='POST'
accept-charset='utf-8'
class='w-full'
captcha-form
>
<input type='hidden' name='gdpr' value='true' />
<div class='flex w-full flex-col items-stretch gap-2'>
<GitHubButton client:load />
<GoogleButton client:load />
</div>
<input
type='email'
required
name='email'
id='email'
autofocus
class='mt-1 block w-full mb-2 border-2 rounded-md py-2 sm:py-3 px-3 sm:px-3.5 text-md'
placeholder='Enter your email'
/>
<Divider />
<CaptchaFields />
<EmailSignupForm client:load />
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
<input type='hidden' name='subform' value='yes' />
<button
type='submit'
name='submit'
class='bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-600 hover:to-blue-800 text-white py-2 sm:py-2.5 sm:px-5 rounded-md w-full text-md'
<div class='mt-6 text-center text-sm text-slate-600'>
Already have an account? <a
href='/login'
class='font-medium text-blue-700 hover:text-blue-600'>Login</a
>
Subscribe
</button>
</form>
</div>
</div>
</div>
<CaptchaScripts slot='after-footer' />
</BaseLayout>
</SettingLayout>

View File

@@ -0,0 +1,10 @@
---
import SettingLayout from '../layouts/SettingLayout.astro';
import { VerificationEmailMessage } from '../components/AuthenticationFlow/VerificationEmailMessage';
---
<SettingLayout title='Verify Email'>
<section class='container py-8 sm:py-20'>
<VerificationEmailMessage client:load />
</section>
</SettingLayout>

View File

@@ -0,0 +1,10 @@
---
import { TriggerVerifyAccount } from '../components/AuthenticationFlow/TriggerVerifyAccount';
import SettingLayout from '../layouts/SettingLayout.astro';
---
<SettingLayout title='Verify account'>
<div class='container py-16'>
<TriggerVerifyAccount client:load />
</div>
</SettingLayout>

3
src/stores/page.ts Normal file
View File

@@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const pageLoadingMessage = atom('');

View File

@@ -1,3 +1,7 @@
{
"extends": "astro/tsconfigs/strict"
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}