feat: user accounts functionality (#3813)

* feat: integrate astro

* chore: login popup design

* chore: data-popup changed

* refactor: github and google button

* chore: signup page

* chore: login popup design

* chore: signup page design

* chore: auth divider

* feat: integrate astro

* chore: login popup design

* chore: data-popup changed

* refactor: github and google button

* chore: signup page

* chore: login popup design

* chore: signup page design

* chore: auth divider

* chore: login feature

* chore: login error message

* chore: added name in token decode return

* chore: use auth hook

* chore: logout vs login

* chore: download button link

* chore: account dropdown

* fix: dropdown z index

* chore: profile page

* Add missing content for backend roadmap

* Remove unused styles

* Add login with google

* chore: google login implementation

* chore: profile guard clause

* fix: button size

* chore: preact to astro components

* chore: preact to astro comp

* chore: github astro component

* chore: google login error handling

* chore: github login error handling

* chore: change password page

* chore: rename profile to password

* fix: change password rename

* chore: update profile page

* chore: setting sidebar

* fix: setting dropdown design

* chore: required indicator

* chore: change password form

* chore: update profile form

* chore: mobile navigation

* fix: form data empty error

* chore: email login and signup components

* chore: forgot password page

* chore: reset password page

* chore: verify account page

* chore: resend verification email

* fix: types in spinner

* chore: forgot password functionality

* fix: class -> className

* chore: reset password page

* chore: reset password functionality

* chore: login page

* fix: spacing for login and signup page

* refactor: email login form

* chore: astro spinner

* chore: pre-fill user data

* chore: dummy placeholder

* chore: forgot password link add

* fix: replaced constants

* chore: forgot password link

* chore: change password for social provider

* chore: internal pages guard

* chore: internal paths

* refactor: change password errors

* refactor: update profile errors

* chore: mark as done overlay

* fix: uncontrolled to controlled form

* fix: de-structure error

* chore: error messages

* fix: 401 error code redirect to login page

* chore: loading spinner accessibilities

* fix: remove spinner

* chore: keep spinner after success to redirect

* chore: keep the spinner

* style: resend email underline

* chore: chevron down account

* chore: roadmap pdf link download

* chore: roadmap pdf link download

* chore: best practices buttons

* fix: verify account text

* fix: topic overlay hide

* chore: base verify design

* chore: email verify page

* fix: div tag missing

* Formatting

* Refactor top navigation

* Prettier

* Update dependencies

* Refactor top navigation

* Refactor login button

* Remove captcha and add google scripts

* Refactor email sign up form

* Resend verfication email functionality

* Refactor verification pending page

* Add verify account functionality

* Update signup text

* Add login page

* Add login button in top nav

* Email login form

* Handle authenticatoin

* Show hide auth elements change

* Add ease-in on the guest elements

* Refactor logic for download and subscribe popups

* Add forgot password

* Rename fetch lib

* Add authentication popup

* Refactor logic for mark done and pending

* Handle logout

* Add route protection

* Popup opener to close the overlay

* Remember page when logging in

* Add reset password page

* Change placement of constant

* Update profile page

* Add update password form

* Update password page

* Update profile page

* Update design

* chore: toggle mark resource done api

* chore: toggle topic done

* chore: get user resource progress api

* fix: best practice topic toggle

* chore: fetch progress

* fix: query selector for topics

* Keep track of the old page before social login

* Update public api url

* Add user progress tracking

* Update topic done functionality

* Add progress loader

* Add page wide spinner

* Add spinner on setting pages

* Add fingerprint to user requests

* Use http wrapper instead of fetch

* Update fingerprint

* Minor improvements

---------

Co-authored-by: Arik Chakma <arikchangma@gmail.com>
pull/3816/head
Kamran Ahmed 2 years ago committed by GitHub
parent c5645299aa
commit a2719bc771
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .env.example
  2. 1
      .github/workflows/deploy.yml
  3. 5
      astro.config.mjs
  4. 20
      package.json
  5. 1611
      pnpm-lock.yaml
  6. 5
      src/components/Analytics/Analytics.astro
  7. 3
      src/components/AstroIcon.astro
  8. 5
      src/components/AuthenticationFlow/Divider.astro
  9. 100
      src/components/AuthenticationFlow/EmailLoginForm.tsx
  10. 103
      src/components/AuthenticationFlow/EmailSignupForm.tsx
  11. 64
      src/components/AuthenticationFlow/ForgotPasswordForm.tsx
  12. 116
      src/components/AuthenticationFlow/GitHubButton.tsx
  13. 116
      src/components/AuthenticationFlow/GoogleButton.tsx
  14. 32
      src/components/AuthenticationFlow/LoginPopup.astro
  15. 97
      src/components/AuthenticationFlow/ResetPasswordForm.tsx
  16. 79
      src/components/AuthenticationFlow/TriggerVerifyAccount.tsx
  17. 85
      src/components/AuthenticationFlow/VerificationEmailMessage.tsx
  18. 4
      src/components/Authenticator/Authenticator.astro
  19. 79
      src/components/Authenticator/authenticator.ts
  20. 52
      src/components/BestPracticeHeader.astro
  21. 2
      src/components/Breadcrumbs.astro
  22. 50
      src/components/DownloadPopup.astro
  23. 2
      src/components/FAQs/Question.astro
  24. 2
      src/components/Footer.astro
  25. 6
      src/components/FrameRenderer/FrameRenderer.astro
  26. 59
      src/components/FrameRenderer/renderer.ts
  27. 2
      src/components/Loader.astro
  28. 79
      src/components/Navigation.astro
  29. 44
      src/components/Navigation/AccountDropdown.astro
  30. 133
      src/components/Navigation/Navigation.astro
  31. 39
      src/components/Navigation/navigation.ts
  32. 2
      src/components/OpenSourceBanner.astro
  33. 29
      src/components/PageProgress.tsx
  34. 2
      src/components/Popup/Popup.astro
  35. 80
      src/components/RoadmapHeader.astro
  36. 2
      src/components/RoadmapHint.astro
  37. 81
      src/components/Setting/SettingSidebar.astro
  38. 175
      src/components/Setting/UpdatePasswordForm.tsx
  39. 203
      src/components/Setting/UpdateProfileForm.tsx
  40. 2
      src/components/ShareIcons/ShareIcons.astro
  41. 2
      src/components/Sponsor/Sponsor.astro
  42. 41
      src/components/SubscribePopup.astro
  43. 261
      src/components/TopicDetail/TopicDetail.tsx
  44. 64
      src/components/TopicOverlay/TopicOverlay.astro
  45. 79
      src/components/TopicOverlay/topic.js
  46. 2
      src/components/TopicSearch/TopicSearch.astro
  47. 2
      src/components/UpcomingForm.astro
  48. 2
      src/components/YouTubeBanner.astro
  49. 1
      src/env.d.ts
  50. 16
      src/hooks/use-keydown.ts
  51. 30
      src/hooks/use-load-topic.ts
  52. 20
      src/hooks/use-outside-click.ts
  53. 30
      src/hooks/use-toggle-topic.ts
  54. 6
      src/icons/check.svg
  55. 3
      src/icons/chevron-down.svg
  56. 3
      src/icons/dropdown.svg
  57. 18
      src/icons/error.svg
  58. 1
      src/icons/github.svg
  59. 1
      src/icons/google.svg
  60. 8
      src/icons/reset.svg
  61. 6
      src/icons/spinner.svg
  62. 1
      src/icons/verify-letter.svg
  63. 66
      src/layouts/BaseLayout.astro
  64. 12
      src/layouts/SettingLayout.astro
  65. 153
      src/lib/http.ts
  66. 22
      src/lib/jwt.ts
  67. 163
      src/lib/resource-progress.ts
  68. 14
      src/lib/roadmap-topic.ts
  69. 2
      src/pages/404.astro
  70. 38
      src/pages/[roadmapId]/index.astro
  71. 30
      src/pages/best-practices/[bestPracticeId]/index.astro
  72. 32
      src/pages/forgot-password.astro
  73. 41
      src/pages/login.astro
  74. 43
      src/pages/pdfs.md
  75. 23
      src/pages/reset-password.astro
  76. 11
      src/pages/settings/update-password.astro
  77. 11
      src/pages/settings/update-profile.astro
  78. 72
      src/pages/signup.astro
  79. 10
      src/pages/verification-pending.astro
  80. 10
      src/pages/verify-account.astro
  81. 3
      src/stores/page.ts
  82. 6
      tsconfig.json

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

@ -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>

@ -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;

@ -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;

@ -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>
);
}

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

@ -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>
)}
</>
);
}

@ -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>

@ -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>
);
}

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

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

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

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

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

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

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

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

@ -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'> <div class='py-6 sm:py-16 pb-10 bg-slate-900 text-white'>

@ -17,7 +17,9 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
<div <div
id='resource-svg-wrap' 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-type={resourceType}
data-resource-id={resourceId} data-resource-id={resourceId}
data-json-url={jsonUrl} data-json-url={jsonUrl}
@ -27,4 +29,4 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
</div> </div>
</div> </div>
<script src='./renderer.js'></script> <script src='./renderer.ts'></script>

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

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

@ -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>

@ -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>

@ -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>

@ -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();

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

@ -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>
);
}

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

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

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

@ -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>

@ -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>
);
}

@ -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>
);
}

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

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

@ -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>

@ -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" class="h-4 w-4" 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>
);
}

@ -1,5 +1,5 @@
--- ---
import Icon from '../Icon.astro'; import Icon from '../AstroIcon.astro';
import Loader from '../Loader.astro'; import Loader from '../Loader.astro';
export interface Props { export interface Props {
@ -11,7 +11,7 @@ const { contentContributionLink } = Astro.props;
<div id='topic-overlay' class='hidden'> <div id='topic-overlay' class='hidden'>
<div <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' tabindex='-1'
id='topic-body' id='topic-body'
> >
@ -19,33 +19,39 @@ const { contentContributionLink } = Astro.props;
<Loader /> <Loader />
</div> </div>
<div id='topic-actions' class='hidden mb-2'> <div id='topic-actions' class='mb-2 hidden'>
<button <div data-guest-required class='hidden'>
id='mark-topic-done' <button
ga-category='TopicClick' data-popup='login-popup'
ga-action='topic/mark-completion' class='inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700'
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>
<Icon icon='check' /> </button>
<span class='ml-2'>Mark as Done</span> </div>
</button>
<button <div data-auth-required>
id='mark-topic-pending' <button
ga-category='TopicClick' id='mark-topic-done'
ga-action='topic/mark-completion' class='inline-flex hidden items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700'
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='check' />
> <span class='ml-2'>Mark as Done</span>
<Icon icon='reset' /> </button>
<span class='ml-2'>Mark as Pending</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 <button
type='button' type='button'
id='close-topic' 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' /> <Icon icon='close' />
</button> </button>
@ -53,24 +59,24 @@ const { contentContributionLink } = Astro.props;
<div <div
id='topic-content' 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> </div>
<p <p
id='contrib-meta' 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 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 brief description and a few links to learn more about this topic <a
target='_blank' target='_blank'
class='underline text-blue-700' class='text-blue-700 underline'
href={contentContributionLink}>on GitHub repository.</a href={contentContributionLink}>on GitHub repository.</a
>. >.
</p> </p>
</div> </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>
</div> </div>
<script src="./topic.js" /> <script src='./topic.js'></script>

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

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

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

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

1
src/env.d.ts vendored

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

@ -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);
};
}, []);
}

@ -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);
};
}, []);
}

@ -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]);
}

@ -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
);
};
}, []);
}

@ -1,5 +1,3 @@
<svg viewBox="0 0 14 14" focusable="false" class="h-3 w-3" aria-hidden="true"> <svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor"> <path d="M5.5 9.99933L14 1.49933L12.5 0L5.5 6.99933L1.5 2.99687L0 4.49933L5.5 9.99933Z" fill="white"/>
<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> </svg>

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 208 B

@ -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

@ -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

@ -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

@ -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

@ -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

@ -1,6 +1,4 @@
<svg viewBox="0 0 24 24" focusable="false" class="w-3 h-3" aria-hidden="true"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor"> <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="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.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"/>
<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> </svg>

Before

Width:  |  Height:  |  Size: 932 B

After

Width:  |  Height:  |  Size: 3.4 KiB

@ -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"> <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 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="currentFill"/> <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> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -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

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

@ -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>

@ -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',
});
}

@ -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;
}

@ -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);
});
}

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

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

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

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

@ -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>

@ -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>

@ -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)

@ -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>

@ -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>

@ -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>

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

@ -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>

@ -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>

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

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

Loading…
Cancel
Save