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>
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
PUBLIC_API_URL=http://api.roadmap.sh
|
1
.github/workflows/deploy.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
env:
|
||||
PUBLIC_API_URL: "https://api.roadmap.sh"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PAT: ${{ secrets.PAT }}
|
||||
CI: true
|
||||
|
@@ -1,11 +1,13 @@
|
||||
// https://astro.build/config
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import compress from 'astro-compress';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import compress from 'astro-compress';
|
||||
import rehypeExternalLinks from 'rehype-external-links';
|
||||
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
|
||||
import preact from '@astrojs/preact';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://roadmap.sh/',
|
||||
markdown: {
|
||||
@@ -56,5 +58,6 @@ export default defineConfig({
|
||||
css: false,
|
||||
js: false,
|
||||
}),
|
||||
preact(),
|
||||
],
|
||||
});
|
||||
|
20
package.json
@@ -20,25 +20,33 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^1.2.1",
|
||||
"@astrojs/preact": "^2.1.0",
|
||||
"@astrojs/sitemap": "^1.2.2",
|
||||
"@astrojs/tailwind": "^3.1.1",
|
||||
"astro": "^2.1.9",
|
||||
"@fingerprintjs/fingerprintjs": "^3.4.1",
|
||||
"@nanostores/preact": "^0.3.1",
|
||||
"astro": "^2.2.3",
|
||||
"astro-compress": "^1.1.35",
|
||||
"jose": "^4.13.2",
|
||||
"js-cookie": "^3.0.1",
|
||||
"nanostores": "^0.7.4",
|
||||
"node-html-parser": "^6.1.5",
|
||||
"npm-check-updates": "^16.9.0",
|
||||
"npm-check-updates": "^16.10.8",
|
||||
"preact": "^10.13.2",
|
||||
"rehype-external-links": "^2.0.1",
|
||||
"roadmap-renderer": "^1.0.4",
|
||||
"roadmap-renderer": "^1.0.5",
|
||||
"tailwindcss": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.32.1",
|
||||
"@playwright/test": "^1.32.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-cookie": "^3.0.3",
|
||||
"gh-pages": "^5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"markdown-it": "^13.0.1",
|
||||
"openai": "^3.2.1",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-astro": "^0.8.0",
|
||||
"prettier-plugin-tailwindcss": "^0.2.6"
|
||||
"prettier-plugin-tailwindcss": "^0.2.7"
|
||||
}
|
||||
}
|
||||
|
1615
pnpm-lock.yaml
generated
@@ -1,17 +1,18 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src='./analytics.js'></script>
|
||||
<script src='./analytics.ts'></script>
|
||||
<script async src='https://www.googletagmanager.com/gtag/js?id=UA-139582634-1'
|
||||
></script>
|
||||
<script is:inline>
|
||||
// @ts-nocheck
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'UA-139582634-1');
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
|
@@ -35,5 +35,4 @@ const { attributes: baseAttributes, innerHTML } = await getSVG(icon);
|
||||
const svgAttributes = { ...baseAttributes, ...attributes };
|
||||
---
|
||||
|
||||
|
||||
<svg {...svgAttributes} set:html={innerHTML}></svg>
|
5
src/components/AuthenticationFlow/Divider.astro
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class='flex w-full items-center gap-2 py-6 text-sm text-slate-600'>
|
||||
<div class='h-px w-full bg-slate-200'></div>
|
||||
OR
|
||||
<div class='h-px w-full bg-slate-200'></div>
|
||||
</div>
|
100
src/components/AuthenticationFlow/EmailLoginForm.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
const EmailLoginForm: FunctionComponent<{}> = () => {
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const handleFormSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-login`,
|
||||
{
|
||||
email,
|
||||
password,
|
||||
}
|
||||
);
|
||||
|
||||
// Log the user in and reload the page
|
||||
if (response?.token) {
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
window.location.reload();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// @todo use proper types
|
||||
if ((error as any).type === 'user_not_verified') {
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong. Please try again later.');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full" onSubmit={handleFormSubmit}>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
<p class="mb-3 mt-2 text-sm text-gray-500">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
className="text-blue-800 hover:text-blue-600"
|
||||
>
|
||||
Reset your password?
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mb-2 rounded-md bg-red-100 p-2 text-red-800">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailLoginForm;
|
103
src/components/AuthenticationFlow/EmailSignupForm.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { FunctionComponent } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
const EmailSignupForm: FunctionComponent = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost<{ status: 'ok' }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-register`,
|
||||
{
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || response?.status !== 'ok') {
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/verification-pending?email=${encodeURIComponent(
|
||||
email
|
||||
)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}>
|
||||
<label htmlFor="name" className="sr-only">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
min={3}
|
||||
max={50}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Full Name"
|
||||
value={name}
|
||||
onInput={(e) => setName(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail(String((e.target as any).value))}
|
||||
/>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
min={6}
|
||||
max={50}
|
||||
required
|
||||
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword(String((e.target as any).value))}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-100 p-2 text-red-700">{error}.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue to Verify Email'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSignupForm;
|
64
src/components/AuthenticationFlow/ForgotPasswordForm.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-forgot-password`,
|
||||
{
|
||||
email,
|
||||
}
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setEmail('');
|
||||
setSuccess('Check your email for a link to reset your password.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} class="w-full">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
placeholder="Email Address"
|
||||
value={email}
|
||||
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p className="mt-2 rounded-lg bg-green-100 p-2 text-sm text-green-700">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
116
src/components/AuthenticationFlow/GitHubButton.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
import GitHubIcon from '../../icons/github.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type GitHubButtonProps = {};
|
||||
|
||||
const GITHUB_REDIRECT_AT = 'githubRedirectAt';
|
||||
const GITHUB_LAST_PAGE = 'githubLastPage';
|
||||
|
||||
export function GitHubButton(props: GitHubButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GitHubIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'github') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
const errMessage = error?.message || 'Something went wrong.';
|
||||
setError(errMessage);
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT);
|
||||
const lastPageBeforeGithub = localStorage.getItem(GITHUB_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (gitHubRedirectAt && lastPageBeforeGithub) {
|
||||
const socialRedirectAtTime = parseInt(gitHubRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeGithub;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { response, error } = await httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-github-login`
|
||||
);
|
||||
|
||||
if (error || !response?.loginUrl) {
|
||||
setError(
|
||||
error?.message || 'Something went wrong. Please try again later.'
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="GitHub"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
116
src/components/AuthenticationFlow/GoogleButton.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import GoogleIcon from '../../icons/google.svg';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet } from '../../lib/http';
|
||||
|
||||
type GoogleButtonProps = {};
|
||||
|
||||
const GOOGLE_REDIRECT_AT = 'googleRedirectAt';
|
||||
const GOOGLE_LAST_PAGE = 'googleLastPage';
|
||||
|
||||
export function GoogleButton(props: GoogleButtonProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const icon = isLoading ? SpinnerIcon : GoogleIcon;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const state = urlParams.get('state');
|
||||
const provider = urlParams.get('provider');
|
||||
|
||||
if (!code || !state || provider !== 'google') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
httpGet<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${
|
||||
window.location.search
|
||||
}`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectUrl = '/';
|
||||
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT);
|
||||
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_LAST_PAGE);
|
||||
|
||||
// If the social redirect is there and less than 30 seconds old
|
||||
// redirect to the page that user was on before they clicked the github login button
|
||||
if (googleRedirectAt && lastPageBeforeGoogle) {
|
||||
const socialRedirectAtTime = parseInt(googleRedirectAt, 10);
|
||||
const now = Date.now();
|
||||
const timeSinceRedirect = now - socialRedirectAtTime;
|
||||
|
||||
if (timeSinceRedirect < 30 * 1000) {
|
||||
redirectUrl = lastPageBeforeGoogle;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
window.location.href = redirectUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsLoading(true);
|
||||
httpGet<{ loginUrl: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-google-login`
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.loginUrl) {
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname);
|
||||
}
|
||||
|
||||
window.location.href = response.loginUrl;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isLoading}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Google"
|
||||
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Continue with Google
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
32
src/components/AuthenticationFlow/LoginPopup.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import Popup from '../Popup/Popup.astro';
|
||||
import EmailLoginForm from './EmailLoginForm';
|
||||
import Divider from './Divider.astro';
|
||||
import { GitHubButton } from './GitHubButton';
|
||||
import { GoogleButton } from './GoogleButton';
|
||||
---
|
||||
|
||||
<Popup id='login-popup' title='' subtitle=''>
|
||||
<div class='text-center'>
|
||||
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
|
||||
Login to your account
|
||||
</h2>
|
||||
<p class='mt-2 text-sm leading-4 text-slate-600'>
|
||||
You must be logged in to perform this action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class='mt-7 flex flex-col gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<EmailLoginForm client:load />
|
||||
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Don't have an account?{' '}
|
||||
<a href='/signup' class='font-medium text-[#4285f4]'> Sign up</a>
|
||||
</div>
|
||||
</Popup>
|
97
src/components/AuthenticationFlow/ResetPasswordForm.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
setCode(code);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
setIsLoading(false);
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-reset-forgotten-password`,
|
||||
{
|
||||
newPassword: password,
|
||||
confirmPassword: passwordConfirm,
|
||||
code,
|
||||
}
|
||||
);
|
||||
|
||||
if (error?.message) {
|
||||
setIsLoading(false);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response?.token) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = response.token;
|
||||
Cookies.set(TOKEN_COOKIE_NAME, token);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="mx-auto w-full" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
className="mb-2 mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New Password"
|
||||
value={password}
|
||||
onInput={(e) => setPassword((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Confirm New Password"
|
||||
value={passwordConfirm}
|
||||
onInput={(e) =>
|
||||
setPasswordConfirm((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Reset Password'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
79
src/components/AuthenticationFlow/TriggerVerifyAccount.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import ErrorIcon from '../../icons/error.svg';
|
||||
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function TriggerVerifyAccount() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const triggerVerify = (code: string) => {
|
||||
setIsLoading(true);
|
||||
|
||||
httpPost<{ token: string }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`,
|
||||
{
|
||||
code,
|
||||
}
|
||||
)
|
||||
.then(({ response, error }) => {
|
||||
if (!response?.token) {
|
||||
setError(error?.message || 'Something went wrong. Please try again.');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token);
|
||||
window.location.href = '/';
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again.');
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code')!;
|
||||
|
||||
if (!code) {
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
return;
|
||||
}
|
||||
|
||||
triggerVerify(code);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
{isLoading && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={SpinnerIcon}
|
||||
class={'mx-auto h-16 w-16 animate-spin'}
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<img
|
||||
alt={'Please wait.'}
|
||||
src={ErrorIcon}
|
||||
className={'mx-auto h-16 w-16'}
|
||||
/>
|
||||
)}
|
||||
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
|
||||
Verifying your account
|
||||
</h2>
|
||||
<div className="text-sm sm:text-base">
|
||||
{isLoading && <p>Please wait while we verify your account..</p>}
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -0,0 +1,85 @@
|
||||
import VerifyLetterIcon from '../../icons/verify-letter.svg';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
export function VerificationEmailMessage() {
|
||||
const [email, setEmail] = useState('..');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEmailResent, setIsEmailResent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
setEmail(urlParams.get('email')!);
|
||||
}, []);
|
||||
|
||||
const resendVerificationEmail = () => {
|
||||
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-send-verification-email`, {
|
||||
email,
|
||||
})
|
||||
.then(({ response, error }) => {
|
||||
if (error) {
|
||||
setIsEmailResent(false);
|
||||
setError(error?.message || 'Something went wrong.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEmailResent(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsEmailResent(false);
|
||||
setIsLoading(false);
|
||||
setError('Something went wrong. Please try again later.');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md text-center">
|
||||
<img
|
||||
alt="Verify Email"
|
||||
src={VerifyLetterIcon}
|
||||
class="mx-auto mb-4 h-20 w-40 sm:h-40"
|
||||
/>
|
||||
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
|
||||
Verify your email address
|
||||
</h2>
|
||||
<div class="text-sm sm:text-base">
|
||||
<p>
|
||||
We have sent you an email at{' '}
|
||||
<span className="font-bold">{email}</span>. Please click the link to
|
||||
verify your account. This link will expire shortly, so please verify
|
||||
soon!
|
||||
</p>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
{!isEmailResent && (
|
||||
<>
|
||||
{isLoading && <p className="text-gray-400">Sending the email ..</p>}
|
||||
{!isLoading && !error && (
|
||||
<p>
|
||||
Please make sure to check your spam folder. If you still don't
|
||||
have the email click to{' '}
|
||||
<button
|
||||
disabled={!email}
|
||||
className="inline text-blue-700"
|
||||
onClick={resendVerificationEmail}
|
||||
>
|
||||
resend verification email.
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p class="text-red-700">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEmailResent && (
|
||||
<p class="text-green-700">Verification email has been sent!</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
4
src/components/Authenticator/Authenticator.astro
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src='./authenticator.ts'></script>
|
79
src/components/Authenticator/authenticator.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
|
||||
function easeInElement(el: Element) {
|
||||
el.classList.add('opacity-0', 'transition-opacity', 'duration-300');
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('opacity-0');
|
||||
});
|
||||
}
|
||||
|
||||
function showHideAuthElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
document.querySelectorAll('[data-auth-required]').forEach((el) => {
|
||||
if (hideOrShow === 'hide') {
|
||||
el.classList.add('hidden');
|
||||
} else {
|
||||
easeInElement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') {
|
||||
document.querySelectorAll('[data-guest-required]').forEach((el) => {
|
||||
if (hideOrShow === 'hide') {
|
||||
el.classList.add('hidden');
|
||||
} else {
|
||||
easeInElement(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Prepares the UI for the user who is logged in
|
||||
function handleGuest() {
|
||||
const authenticatedRoutes = [
|
||||
'/settings/update-profile',
|
||||
'/settings/update-password',
|
||||
];
|
||||
|
||||
showHideAuthElements('hide');
|
||||
showHideGuestElements('show');
|
||||
|
||||
// If the user is on an authenticated route, redirect them to the home page
|
||||
if (authenticatedRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Prepares the UI for the user who is logged out
|
||||
function handleAuthenticated() {
|
||||
const guestRoutes = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/verify-account',
|
||||
'/verification-pending',
|
||||
'/reset-password',
|
||||
'/forgot-password',
|
||||
];
|
||||
|
||||
showHideGuestElements('hide');
|
||||
showHideAuthElements('show');
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAuthRequired() {
|
||||
const token = Cookies.get(TOKEN_COOKIE_NAME);
|
||||
if (token) {
|
||||
handleAuthenticated();
|
||||
} else {
|
||||
handleGuest();
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
handleAuthRequired();
|
||||
}, 0);
|
@@ -1,7 +1,8 @@
|
||||
---
|
||||
import BestPracticeHint from './BestPracticeHint.astro';
|
||||
import DownloadPopup from './DownloadPopup.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import SubscribePopup from './SubscribePopup.astro';
|
||||
|
||||
export interface Props {
|
||||
@@ -15,23 +16,22 @@ const { title, description, bestPracticeId, isUpcoming = false } = Astro.props;
|
||||
const isBestPracticeReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<DownloadPopup />
|
||||
<SubscribePopup />
|
||||
<LoginPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='py-5 sm:py-12 container relative'>
|
||||
<div class='mt-0 mb-3 sm:mb-6'>
|
||||
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<div class='mb-3 mt-0 sm:mb-6'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
{title}
|
||||
</h1>
|
||||
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
|
||||
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between'>
|
||||
<div class='flex gap-1 sm:gap-2'>
|
||||
<a
|
||||
href='/best-practices'
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to All Best Practices'
|
||||
>
|
||||
←<span class='hidden sm:inline'> All Best Practices</span>
|
||||
@@ -40,22 +40,42 @@ const isBestPracticeReady = !isUpcoming;
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<button
|
||||
data-popup='download-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
aria-label='Download Best Practice'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='hidden inline-flex items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Download Best Practice Popup'
|
||||
ga-label='Download Roadmap Popup'
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='hidden sm:inline ml-2'>Download</span>
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
isBestPracticeReady && (
|
||||
<a
|
||||
data-auth-required
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Download Roadmap Popup'
|
||||
target="_blank"
|
||||
href={`/pdfs/best-practices/${bestPracticeId}.pdf`}
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
<button
|
||||
data-popup='subscribe-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Subscribe for Updates'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
@@ -71,7 +91,7 @@ const isBestPracticeReady = !isUpcoming;
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
|
||||
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Suggest Changes'
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
|
@@ -10,7 +10,7 @@ const { breadcrumbs, roadmapId } = Astro.props;
|
||||
---
|
||||
|
||||
<div class='py-7 pb-6'>
|
||||
<!-- Desktop breadcrums -->
|
||||
<!-- Desktop breadcrumbs -->
|
||||
<p class='text-gray-500 container hidden sm:block'>
|
||||
{
|
||||
breadcrumbs.map((breadcrumb, counter) => {
|
||||
|
@@ -1,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 {
|
||||
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'>
|
||||
|
@@ -17,7 +17,9 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
|
||||
|
||||
<div
|
||||
id='resource-svg-wrap'
|
||||
style={dimensions ? `--aspect-ratio:${dimensions.width}/${dimensions.height}` : null}
|
||||
style={dimensions
|
||||
? `--aspect-ratio:${dimensions.width}/${dimensions.height}`
|
||||
: null}
|
||||
data-resource-type={resourceType}
|
||||
data-resource-id={resourceId}
|
||||
data-json-url={jsonUrl}
|
||||
@@ -27,4 +29,4 @@ const { resourceId, resourceType, jsonUrl, dimensions = null } = Astro.props;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src='./renderer.js'></script>
|
||||
<script src='./renderer.ts'></script>
|
||||
|
@@ -1,6 +1,18 @@
|
||||
import { wireframeJSONToSVG } from 'roadmap-renderer';
|
||||
import {
|
||||
renderResourceProgress,
|
||||
ResourceType,
|
||||
} from '../../lib/resource-progress';
|
||||
|
||||
export class Renderer {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
jsonUrl: string;
|
||||
loaderHTML: string | null;
|
||||
|
||||
containerId: string;
|
||||
loaderId: string;
|
||||
|
||||
constructor() {
|
||||
this.resourceId = '';
|
||||
this.resourceType = '';
|
||||
@@ -32,12 +44,12 @@ export class Renderer {
|
||||
}
|
||||
|
||||
// Clone it so we can use it later
|
||||
this.loaderHTML = this.loaderEl.innerHTML;
|
||||
this.loaderHTML = this.loaderEl!.innerHTML;
|
||||
const dataset = this.containerEl.dataset;
|
||||
|
||||
this.resourceType = dataset.resourceType;
|
||||
this.resourceId = dataset.resourceId;
|
||||
this.jsonUrl = dataset.jsonUrl;
|
||||
this.resourceType = dataset.resourceType!;
|
||||
this.resourceId = dataset.resourceId!;
|
||||
this.jsonUrl = dataset.jsonUrl!;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -46,13 +58,17 @@ export class Renderer {
|
||||
* @param { string } jsonUrl
|
||||
* @returns {Promise<SVGElement>}
|
||||
*/
|
||||
jsonToSvg(jsonUrl) {
|
||||
jsonToSvg(jsonUrl: string) {
|
||||
if (!jsonUrl) {
|
||||
console.error('jsonUrl not defined in frontmatter');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.containerEl.innerHTML = this.loaderHTML;
|
||||
if (!this.containerEl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.containerEl.innerHTML = this.loaderHTML!;
|
||||
|
||||
return fetch(jsonUrl)
|
||||
.then((res) => {
|
||||
@@ -64,9 +80,19 @@ export class Renderer {
|
||||
});
|
||||
})
|
||||
.then((svg) => {
|
||||
this.containerEl.replaceChildren(svg);
|
||||
this.containerEl?.replaceChildren(svg);
|
||||
})
|
||||
.then(() => {
|
||||
return renderResourceProgress(
|
||||
this.resourceType as ResourceType,
|
||||
this.resourceId
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!this.containerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = `
|
||||
<strong>There was an error.</strong><br>
|
||||
|
||||
@@ -74,7 +100,6 @@ export class Renderer {
|
||||
|
||||
${error.message} <br /> ${error.stack}
|
||||
`;
|
||||
|
||||
this.containerEl.innerHTML = `<div class="error py-5 text-center text-red-600 mx-auto">${message}</div>`;
|
||||
});
|
||||
}
|
||||
@@ -94,16 +119,16 @@ export class Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
switchRoadmap(newJsonUrl) {
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop().replace('.json', '');
|
||||
switchRoadmap(newJsonUrl: string) {
|
||||
const newJsonFileSlug = newJsonUrl.split('/').pop()?.replace('.json', '');
|
||||
|
||||
// Update the URL and attach the new roadmap type
|
||||
if (window?.history?.pushState) {
|
||||
const url = new URL(window.location);
|
||||
const url = new URL(window.location.href);
|
||||
const type = this.resourceType[0]; // r for roadmap, b for best-practices
|
||||
|
||||
url.searchParams.delete(type);
|
||||
url.searchParams.set(type, newJsonFileSlug);
|
||||
url.searchParams.set(type, newJsonFileSlug!);
|
||||
|
||||
window.history.pushState(null, '', url.toString());
|
||||
}
|
||||
@@ -119,13 +144,13 @@ export class Renderer {
|
||||
label: `${newJsonFileSlug}`,
|
||||
});
|
||||
|
||||
this.jsonToSvg(newJsonUrl).then(() => {
|
||||
this.containerEl.setAttribute('style', '');
|
||||
this.jsonToSvg(newJsonUrl)?.then(() => {
|
||||
this.containerEl?.setAttribute('style', '');
|
||||
});
|
||||
}
|
||||
|
||||
handleSvgClick(e) {
|
||||
const targetGroup = e.target.closest('g') || {};
|
||||
handleSvgClick(e: any) {
|
||||
const targetGroup = e.target?.closest('g') || {};
|
||||
const groupId = targetGroup.dataset ? targetGroup.dataset.groupId : '';
|
||||
if (!groupId) {
|
||||
return;
|
||||
@@ -167,6 +192,7 @@ export class Renderer {
|
||||
detail: {
|
||||
topicId: normalizedGroupId,
|
||||
resourceId: this.resourceId,
|
||||
resourceType: this.resourceType,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -175,6 +201,7 @@ export class Renderer {
|
||||
init() {
|
||||
window.addEventListener('DOMContentLoaded', this.onDOMLoaded);
|
||||
window.addEventListener('click', this.handleSvgClick);
|
||||
// window.addEventListener('contextmenu', this.handleSvgClick);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<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>
|
44
src/components/Navigation/AccountDropdown.astro
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
---
|
||||
|
||||
<div class='relative hidden' data-auth-required>
|
||||
<button
|
||||
class='flex h-8 w-28 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600'
|
||||
type='button'
|
||||
data-account-button
|
||||
>
|
||||
<span class='inline-flex items-center gap-1.5'>
|
||||
Account
|
||||
<Icon
|
||||
icon='chevron-down'
|
||||
class='relative top-[0.5px] h-3 w-3 stroke-[3px]'
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl'
|
||||
data-account-dropdown
|
||||
>
|
||||
<ul>
|
||||
<li class='px-1'>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class='px-1'>
|
||||
<button
|
||||
class='block w-full rounded px-4 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700'
|
||||
type='button'
|
||||
data-logout-button
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
133
src/components/Navigation/Navigation.astro
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import AccountDropdown from './AccountDropdown.astro';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a class='flex items-center text-lg font-medium text-white' href='/'>
|
||||
<Icon icon='logo' />
|
||||
<span class='ml-3 hidden md:block'>roadmap.sh</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop navigation items -->
|
||||
<ul class='hidden space-x-5 sm:flex sm:items-center'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/best-practices' class='text-gray-400 hover:text-white'
|
||||
>Best Practices</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'>
|
||||
<li data-guest-required class='hidden'>
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
</li>
|
||||
<li>
|
||||
<AccountDropdown />
|
||||
|
||||
<a
|
||||
data-guest-required
|
||||
class='flex hidden h-8 w-28 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-blue-700 px-4 py-2 text-sm font-medium text-white hover:from-blue-500 hover:to-blue-600'
|
||||
href='/signup'
|
||||
>
|
||||
<span>Sign Up</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Mobile Navigation Button -->
|
||||
<button
|
||||
class='block cursor-pointer text-gray-400 hover:text-gray-50 sm:hidden'
|
||||
aria-label='Menu'
|
||||
data-show-mobile-nav
|
||||
>
|
||||
<Icon icon='hamburger' />
|
||||
</button>
|
||||
|
||||
<!-- Mobile Navigation Items -->
|
||||
<div
|
||||
class='fixed bottom-0 left-0 right-0 top-0 z-40 flex hidden items-center bg-slate-900'
|
||||
data-mobile-nav
|
||||
>
|
||||
<button
|
||||
data-close-mobile-nav
|
||||
class='absolute right-6 top-6 block cursor-pointer text-gray-400 hover:text-gray-50'
|
||||
aria-label='Close Menu'
|
||||
>
|
||||
<Icon icon='close' />
|
||||
</button>
|
||||
<ul class='flex w-full flex-col items-center gap-2 md:gap-3'>
|
||||
<li>
|
||||
<a href='/roadmaps' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Roadmaps
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='/best-practices'
|
||||
class='text-xl hover:text-blue-300 md:text-lg'
|
||||
>
|
||||
Best Practices
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/guides' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Guides
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href='/videos' class='text-xl hover:text-blue-300 md:text-lg'>
|
||||
Videos
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Links for logged in users -->
|
||||
<li data-auth-required class='hidden'>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
class='text-xl hover:text-blue-300 md:text-lg'
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li data-auth-required class='hidden'>
|
||||
<button
|
||||
data-logout-button
|
||||
class='text-xl text-red-300 hover:text-red-400 md:text-lg'
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-guest-required
|
||||
href='/signup'
|
||||
class='hidden text-xl text-white md:text-lg'
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
data-guest-required
|
||||
href='/signup'
|
||||
class='hidden text-xl text-green-300 hover:text-green-400 md:text-lg'
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<script src='./navigation.ts'></script>
|
39
src/components/Navigation/navigation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { handleAuthRequired } from '../Authenticator/authenticator';
|
||||
import {TOKEN_COOKIE_NAME} from "../../lib/jwt";
|
||||
|
||||
export function logout() {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
// Reloading will automatically redirect the user if required
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const dataset = {
|
||||
...target.dataset,
|
||||
...target.closest('button')?.dataset,
|
||||
};
|
||||
|
||||
// If the user clicks on the logout button, remove the token cookie
|
||||
if (dataset.logoutButton !== undefined) {
|
||||
logout();
|
||||
} else if (dataset.showMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
|
||||
} else if (dataset.closeMobileNav !== undefined) {
|
||||
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
document
|
||||
.querySelector('[data-account-button]')
|
||||
?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document
|
||||
.querySelector('[data-account-dropdown]')
|
||||
?.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { getFormattedStars } from '../lib/github';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
|
||||
const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
|
||||
---
|
||||
|
29
src/components/PageProgress.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { pageLoadingMessage } from '../stores/page';
|
||||
import SpinnerIcon from '../icons/spinner.svg';
|
||||
|
||||
export function PageProgress() {
|
||||
const $pageLoadingMessage = useStore(pageLoadingMessage);
|
||||
if (!$pageLoadingMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Tailwind based spinner for full page */}
|
||||
<div className="fixed left-0 top-0 z-50 flex h-full w-full items-center justify-center bg-white bg-opacity-75">
|
||||
<div class="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
|
||||
<img
|
||||
src={SpinnerIcon}
|
||||
alt="Loading"
|
||||
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
|
||||
/>
|
||||
<h1 className="ml-2">
|
||||
{$pageLoadingMessage}
|
||||
<span className="animate-pulse">...</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
id: string;
|
||||
|
@@ -1,9 +1,8 @@
|
||||
---
|
||||
import DownloadPopup from './DownloadPopup.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
import LoginPopup from './AuthenticationFlow/LoginPopup.astro';
|
||||
import RoadmapHint from './RoadmapHint.astro';
|
||||
import RoadmapNote from './RoadmapNote.astro';
|
||||
import SubscribePopup from './SubscribePopup.astro';
|
||||
import TopicSearch from './TopicSearch/TopicSearch.astro';
|
||||
import YouTubeAlert from './YouTubeAlert.astro';
|
||||
|
||||
@@ -18,23 +17,31 @@ export interface Props {
|
||||
hasTopics?: boolean;
|
||||
}
|
||||
|
||||
const { title, description, roadmapId, tnsBannerLink, isUpcoming = false, hasSearch = false, note, hasTopics = false } = Astro.props;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
roadmapId,
|
||||
tnsBannerLink,
|
||||
isUpcoming = false,
|
||||
hasSearch = false,
|
||||
note,
|
||||
hasTopics = false,
|
||||
} = Astro.props;
|
||||
|
||||
const isRoadmapReady = !isUpcoming;
|
||||
---
|
||||
|
||||
<DownloadPopup />
|
||||
<SubscribePopup />
|
||||
<LoginPopup />
|
||||
|
||||
<div class='border-b'>
|
||||
<div class='py-5 sm:py-12 container relative'>
|
||||
<div class='container relative py-5 sm:py-12'>
|
||||
<YouTubeAlert />
|
||||
|
||||
<div class='mt-0 mb-3 sm:mb-4 sm:mt-4'>
|
||||
<h1 class='text-2xl sm:text-4xl mb-0.5 sm:mb-2 font-bold'>
|
||||
<div class='mb-3 mt-0 sm:mb-4 sm:mt-4'>
|
||||
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
|
||||
{title}
|
||||
</h1>
|
||||
<p class='text-gray-500 text-sm sm:text-lg'>{description}</p>
|
||||
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
|
||||
</div>
|
||||
|
||||
<div class='flex justify-between'>
|
||||
@@ -44,33 +51,42 @@ const isRoadmapReady = !isUpcoming;
|
||||
<>
|
||||
<a
|
||||
href='/roadmaps'
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to All Roadmaps'
|
||||
>
|
||||
←<span class='hidden sm:inline'> All Roadmaps</span>
|
||||
</a>
|
||||
|
||||
{isRoadmapReady && (
|
||||
<>
|
||||
<button
|
||||
data-popup='download-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Download Roadmap Popup'
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='hidden sm:inline ml-2'>Download</span>
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</button>
|
||||
|
||||
<a
|
||||
data-auth-required
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Download Roadmap'
|
||||
target='_blank'
|
||||
href={`/pdfs/roadmaps/${roadmapId}.pdf`}
|
||||
>
|
||||
<Icon icon='download' />
|
||||
<span class='ml-2 hidden sm:inline'>Download</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-popup='subscribe-popup'
|
||||
class='inline-flex items-center justify-center bg-yellow-400 py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-yellow-500'
|
||||
data-guest-required
|
||||
data-popup='login-popup'
|
||||
class='inline-flex hidden items-center justify-center rounded-md bg-yellow-400 px-3 py-1.5 text-xs font-medium hover:bg-yellow-500 sm:text-sm'
|
||||
aria-label='Subscribe for Updates'
|
||||
ga-category='Subscription'
|
||||
ga-action='Clicked Popup Opener'
|
||||
ga-label='Subscribe Roadmap Popup'
|
||||
>
|
||||
<Icon icon='email' />
|
||||
<span class='ml-2'>Subscribe</span>
|
||||
@@ -83,7 +99,7 @@ const isRoadmapReady = !isUpcoming;
|
||||
hasSearch && (
|
||||
<a
|
||||
href={`/${roadmapId}`}
|
||||
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
|
||||
class='rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Back to Visual Roadmap'
|
||||
>
|
||||
←
|
||||
@@ -98,7 +114,7 @@ const isRoadmapReady = !isUpcoming;
|
||||
<a
|
||||
href={`https://github.com/kamranahmedse/developer-roadmap/issues/new?title=[Suggestion] ${title}`}
|
||||
target='_blank'
|
||||
class='inline-flex items-center justify-center bg-gray-500 text-white py-1.5 px-3 text-xs sm:text-sm font-medium rounded-md hover:bg-gray-600'
|
||||
class='inline-flex items-center justify-center rounded-md bg-gray-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-gray-600 sm:text-sm'
|
||||
aria-label='Suggest Changes'
|
||||
>
|
||||
<Icon icon='comment' class='h-3 w-3' />
|
||||
@@ -110,7 +126,11 @@ const isRoadmapReady = !isUpcoming;
|
||||
</div>
|
||||
|
||||
<!-- Desktop: Roadmap Resources - Alert -->
|
||||
{hasTopics && <RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />}
|
||||
{
|
||||
hasTopics && (
|
||||
<RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />
|
||||
)
|
||||
}
|
||||
|
||||
{hasSearch && <TopicSearch />}
|
||||
</div>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
roadmapId: string;
|
||||
|
81
src/components/Setting/SettingSidebar.astro
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
import Icon from '../AstroIcon.astro';
|
||||
const { pageUrl, name } = Astro.props;
|
||||
|
||||
export interface Props {
|
||||
pageUrl: string;
|
||||
name: string;
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
class='container flex min-h-[calc(100vh-37px-70px)] items-stretch sm:min-h-[calc(100vh-37px-96px)]'
|
||||
>
|
||||
<aside class='hidden w-56 border-r border-slate-200 py-10 pr-5 md:block'>
|
||||
<nav>
|
||||
<ul class='space-y-1'>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}`
|
||||
>Profile</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-password'
|
||||
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}`
|
||||
>Security</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class='grow py-10 pl-0 md:p-10 md:pr-0'>
|
||||
<div class='relative mb-5 md:hidden'>
|
||||
<button
|
||||
class='flex h-10 w-full items-center justify-between rounded-md bg-slate-800 px-2 text-center font-medium text-slate-100'
|
||||
id='settings-menu'
|
||||
>
|
||||
{name}
|
||||
<Icon icon='dropdown' />
|
||||
</button>
|
||||
<ul
|
||||
id='settings-menu-dropdown'
|
||||
class='absolute mt-1 hidden w-full space-y-1.5 rounded-md bg-white p-2 shadow-lg'
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-profile'
|
||||
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}`
|
||||
>Profile</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href='/settings/update-password'
|
||||
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}`
|
||||
>Change password</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const menuButton = document.getElementById('settings-menu');
|
||||
const menuDropdown = document.getElementById('settings-menu-dropdown');
|
||||
|
||||
menuButton?.addEventListener('click', () => {
|
||||
menuDropdown?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton?.contains(e.target as Node)) {
|
||||
menuDropdown?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
175
src/components/Setting/UpdatePasswordForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useCallback, useEffect, useState } from 'preact/hooks';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
|
||||
export default function UpdatePasswordForm() {
|
||||
const [authProvider, setAuthProvider] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('');
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (newPassword !== newPasswordConfirmation) {
|
||||
setError('Passwords do not match');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-password`,
|
||||
{
|
||||
oldPassword: authProvider === 'email' ? currentPassword : 'social-auth',
|
||||
password: newPassword,
|
||||
confirmPassword: newPasswordConfirmation,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setError(error.message || 'Something went wrong');
|
||||
setIsLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setNewPasswordConfirmation('');
|
||||
setSuccess('Password updated successfully');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, response } = await httpGet(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-me`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { authProvider } = response;
|
||||
setAuthProvider(authProvider);
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
pageLoadingMessage.set('Loading profile');
|
||||
loadProfile().finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Password</h2>
|
||||
<p className="mt-2">Use the form below to update your password.</p>
|
||||
<div className="mt-8 space-y-4">
|
||||
{authProvider === 'email' && (
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="current-password"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
disabled={authProvider !== 'email'}
|
||||
type="password"
|
||||
name="current-password"
|
||||
id="current-password"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-100"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="Current password"
|
||||
value={currentPassword}
|
||||
onInput={(e) =>
|
||||
setCurrentPassword((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="new-password"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-password"
|
||||
id="new-password"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onInput={(e) =>
|
||||
setNewPassword((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="new-password-confirmation"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
New Password Confirm
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-password-confirmation"
|
||||
id="new-password-confirmation"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
minLength={6}
|
||||
placeholder="New password confirm"
|
||||
value={newPasswordConfirmation}
|
||||
onInput={(e) =>
|
||||
setNewPasswordConfirmation((e.target as HTMLInputElement).value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p class="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p class="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
203
src/components/Setting/UpdateProfileForm.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
|
||||
export function UpdateProfileForm() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [github, setGithub] = useState('');
|
||||
const [twitter, setTwitter] = useState('');
|
||||
const [linkedin, setLinkedin] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`,
|
||||
{
|
||||
name,
|
||||
github: github || undefined,
|
||||
linkedin: linkedin || undefined,
|
||||
twitter: twitter || undefined,
|
||||
website: website || undefined,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await loadProfile();
|
||||
setSuccess('Profile updated successfully');
|
||||
};
|
||||
|
||||
const loadProfile = async () => {
|
||||
// Set the loading state
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, response } = await httpGet(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-me`
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setIsLoading(false);
|
||||
setError(error?.message || 'Something went wrong');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, email, links } = response;
|
||||
|
||||
setName(name);
|
||||
setEmail(email);
|
||||
setGithub(links?.github || '');
|
||||
setLinkedin(links?.linkedin || '');
|
||||
setTwitter(links?.twitter || '');
|
||||
setWebsite(links?.website || '');
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Make a request to the backend to fill in the form with the current values
|
||||
useEffect(() => {
|
||||
pageLoadingMessage.set('Loading profile');
|
||||
loadProfile().finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2>
|
||||
<p className="mt-2">Update your profile details below.</p>
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="name"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
for="email"
|
||||
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required
|
||||
disabled
|
||||
placeholder="john@example.com"
|
||||
value={email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="github" className="text-sm leading-none text-slate-500">
|
||||
Github
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="github"
|
||||
id="github"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://github.com/username"
|
||||
value={github}
|
||||
onInput={(e) => setGithub((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="twitter" className="text-sm leading-none text-slate-500">
|
||||
Twitter
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="twitter"
|
||||
id="twitter"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://twitter.com/username"
|
||||
value={twitter}
|
||||
onInput={(e) => setTwitter((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="linkedin" className="text-sm leading-none text-slate-500">
|
||||
LinkedIn
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="linkedin"
|
||||
id="linkedin"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://www.linkedin.com/in/username/"
|
||||
value={linkedin}
|
||||
onInput={(e) => setLinkedin((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<label for="website" className="text-sm leading-none text-slate-500">
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
id="website"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
placeholder="https://example.com"
|
||||
value={website}
|
||||
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<p className="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
|
||||
{success}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
|
||||
>
|
||||
{isLoading ? 'Please wait...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
pageUrl: string;
|
||||
|
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import type { GAEventType } from '../Analytics/analytics';
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
|
||||
export type SponsorType = {
|
||||
url: string;
|
||||
|
@@ -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>
|
261
src/components/TopicDetail/TopicDetail.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import SpinnerIcon from '../../icons/spinner.svg';
|
||||
import CheckIcon from '../../icons/check.svg';
|
||||
import ResetIcon from '../../icons/reset.svg';
|
||||
import CloseIcon from '../../icons/close.svg';
|
||||
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
||||
import { useLoadTopic } from '../../hooks/use-load-topic';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import {
|
||||
isTopicDone,
|
||||
renderTopicProgress,
|
||||
ResourceType,
|
||||
toggleMarkTopicDone as toggleMarkTopicDoneApi,
|
||||
} from '../../lib/resource-progress';
|
||||
import { useKeydown } from '../../hooks/use-keydown';
|
||||
import { useToggleTopic } from '../../hooks/use-toggle-topic';
|
||||
import { pageLoadingMessage } from '../../stores/page';
|
||||
|
||||
export function TopicDetail() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [topicHtml, setTopicHtml] = useState('');
|
||||
|
||||
const [isDone, setIsDone] = useState<boolean>();
|
||||
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true);
|
||||
|
||||
const isGuest = useMemo(() => !isLoggedIn(), []);
|
||||
const topicRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Details of the currently loaded topic
|
||||
const [topicId, setTopicId] = useState('');
|
||||
const [resourceId, setResourceId] = useState('');
|
||||
const [resourceType, setResourceType] = useState<ResourceType>('roadmap');
|
||||
|
||||
const showLoginPopup = () => {
|
||||
const popupEl = document.querySelector(`#login-popup`);
|
||||
if (!popupEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
popupEl.classList.remove('hidden');
|
||||
popupEl.classList.add('flex');
|
||||
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]');
|
||||
if (focusEl) {
|
||||
focusEl.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMarkTopicDone = (isDone: boolean) => {
|
||||
setIsUpdatingProgress(true);
|
||||
toggleMarkTopicDoneApi({ topicId, resourceId, resourceType }, isDone)
|
||||
.then(() => {
|
||||
setIsDone(isDone);
|
||||
setIsActive(false);
|
||||
renderTopicProgress(topicId, isDone);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUpdatingProgress(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Load the topic status when the topic detail is active
|
||||
useEffect(() => {
|
||||
if (!topicId || !resourceId || !resourceType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdatingProgress(true);
|
||||
isTopicDone({ topicId, resourceId, resourceType })
|
||||
.then((status: boolean) => {
|
||||
setIsUpdatingProgress(false);
|
||||
setIsDone(status);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [topicId, resourceId, resourceType]);
|
||||
|
||||
// Close the topic detail when user clicks outside the topic detail
|
||||
useOutsideClick(topicRef, () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
useKeydown('Escape', () => {
|
||||
setIsActive(false);
|
||||
});
|
||||
|
||||
// Toggle topic is available even if the component UI is not active
|
||||
// This is used on the best practice screen where we have the checkboxes
|
||||
// to mark the topic as done/undone.
|
||||
useToggleTopic(({ topicId, resourceType, resourceId }) => {
|
||||
if (isGuest) {
|
||||
showLoginPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
pageLoadingMessage.set('Updating');
|
||||
|
||||
// Toggle the topic status
|
||||
isTopicDone({ topicId, resourceId, resourceType })
|
||||
.then((oldIsDone) => {
|
||||
return toggleMarkTopicDoneApi(
|
||||
{
|
||||
topicId,
|
||||
resourceId,
|
||||
resourceType,
|
||||
},
|
||||
!oldIsDone
|
||||
);
|
||||
})
|
||||
.then((newIsDone) => renderTopicProgress(topicId, newIsDone))
|
||||
.catch((err) => {
|
||||
alert(err.message);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoadingMessage.set('');
|
||||
});
|
||||
});
|
||||
|
||||
// Load the topic detail when the topic detail is active
|
||||
useLoadTopic(({ topicId, resourceType, resourceId }) => {
|
||||
setIsLoading(true);
|
||||
setIsActive(true);
|
||||
|
||||
setTopicId(topicId);
|
||||
setResourceType(resourceType);
|
||||
setResourceId(resourceId);
|
||||
|
||||
const topicPartial = topicId.replaceAll(':', '/');
|
||||
const topicUrl =
|
||||
resourceType === 'roadmap'
|
||||
? `/${resourceId}/${topicPartial}`
|
||||
: `/best-practices/${resourceId}/${topicPartial}`;
|
||||
|
||||
httpGet<string>(
|
||||
topicUrl,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/html',
|
||||
},
|
||||
}
|
||||
)
|
||||
.then(({ response }) => {
|
||||
if (!response) {
|
||||
setError('Topic not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// It's full HTML with page body, head etc.
|
||||
// We only need the inner HTML of the #main-content
|
||||
const node = new DOMParser().parseFromString(response, 'text/html');
|
||||
const topicHtml = node?.getElementById('main-content')?.outerHTML || '';
|
||||
|
||||
setIsLoading(false);
|
||||
setTopicHtml(topicHtml);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError('Something went wrong. Please try again later.');
|
||||
setIsLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={topicRef}
|
||||
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex w-full justify-center">
|
||||
<img
|
||||
src={SpinnerIcon}
|
||||
alt="Loading"
|
||||
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
{/* Actions for the topic */}
|
||||
<div className="mb-2">
|
||||
{isGuest && (
|
||||
<button
|
||||
data-popup="login-popup"
|
||||
className="inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700"
|
||||
onClick={() => setIsActive(false)}
|
||||
>
|
||||
<img alt="Check" src={CheckIcon} />
|
||||
<span className="ml-2">Mark as Done</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isGuest && (
|
||||
<>
|
||||
{isUpdatingProgress && (
|
||||
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black">
|
||||
<img
|
||||
alt="Check"
|
||||
class="h-4 w-4 animate-spin"
|
||||
src={SpinnerIcon}
|
||||
/>
|
||||
<span className="ml-2">Updating Status..</span>
|
||||
</button>
|
||||
)}
|
||||
{!isUpdatingProgress && !isDone && (
|
||||
<button
|
||||
className="inline-flex items-center rounded-md border border-green-600 bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700"
|
||||
onClick={() => toggleMarkTopicDone(true)}
|
||||
>
|
||||
<img alt="Check" 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';
|
||||
|
||||
export interface Props {
|
||||
@@ -11,7 +11,7 @@ const { contentContributionLink } = Astro.props;
|
||||
|
||||
<div id='topic-overlay' class='hidden'>
|
||||
<div
|
||||
class='fixed top-0 right-0 z-40 h-screen p-4 sm:p-6 overflow-y-auto bg-white w-full sm:max-w-[600px]'
|
||||
class='fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6'
|
||||
tabindex='-1'
|
||||
id='topic-body'
|
||||
>
|
||||
@@ -19,13 +19,21 @@ const { contentContributionLink } = Astro.props;
|
||||
<Loader />
|
||||
</div>
|
||||
|
||||
<div id='topic-actions' class='hidden mb-2'>
|
||||
<div id='topic-actions' class='mb-2 hidden'>
|
||||
<div data-guest-required class='hidden'>
|
||||
<button
|
||||
data-popup='login-popup'
|
||||
class='inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700'
|
||||
>
|
||||
<Icon icon='check' />
|
||||
<span class='ml-2'>Mark as Done</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div data-auth-required>
|
||||
<button
|
||||
id='mark-topic-done'
|
||||
ga-category='TopicClick'
|
||||
ga-action='topic/mark-completion'
|
||||
ga-label='done'
|
||||
class='bg-green-600 text-white p-1 px-2 text-sm rounded-md hover:bg-green-700 inline-flex items-center'
|
||||
class='inline-flex hidden items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700'
|
||||
>
|
||||
<Icon icon='check' />
|
||||
<span class='ml-2'>Mark as Done</span>
|
||||
@@ -33,19 +41,17 @@ const { contentContributionLink } = Astro.props;
|
||||
|
||||
<button
|
||||
id='mark-topic-pending'
|
||||
ga-category='TopicClick'
|
||||
ga-action='topic/mark-completion'
|
||||
ga-label='pending'
|
||||
class='hidden bg-red-600 text-white p-1 px-2 text-sm rounded-md hover:bg-red-700 inline-flex items-center'
|
||||
class='inline-flex hidden items-center rounded-md bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700'
|
||||
>
|
||||
<Icon icon='reset' />
|
||||
<span class='ml-2'>Mark as Pending</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
id='close-topic'
|
||||
class='text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 absolute top-2.5 right-2.5 inline-flex items-center'
|
||||
class='absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900'
|
||||
>
|
||||
<Icon icon='close' />
|
||||
</button>
|
||||
@@ -53,24 +59,24 @@ const { contentContributionLink } = Astro.props;
|
||||
|
||||
<div
|
||||
id='topic-content'
|
||||
class='prose prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-quoteless prose-blockquote:font-normal prose-h1:mt-7 prose-h1:mb-2.5 prose-p:mt-0 prose-p:mb-2 prose-li:m-0 prose-li:mb-0.5 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mt-[10px] prose-h3:mb-[5px]'
|
||||
class='prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5'
|
||||
>
|
||||
</div>
|
||||
|
||||
<p
|
||||
id='contrib-meta'
|
||||
class='text-gray-400 text-sm border-t pt-3 mt-10 hidden'
|
||||
class='mt-10 hidden border-t pt-3 text-sm text-gray-400'
|
||||
>
|
||||
We are still working on this page. You can contribute by submitting a
|
||||
brief description and a few links to learn more about this topic <a
|
||||
target='_blank'
|
||||
class='underline text-blue-700'
|
||||
class='text-blue-700 underline'
|
||||
href={contentContributionLink}>on GitHub repository.</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
<div class='bg-gray-900 bg-opacity-50 dark:bg-opacity-80 fixed inset-0 z-30'>
|
||||
<div class='fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80'>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./topic.js" />
|
||||
<script src='./topic.js'></script>
|
||||
|
@@ -29,7 +29,6 @@ export class Topic {
|
||||
this.markAsDone = this.markAsDone.bind(this);
|
||||
this.markAsPending = this.markAsPending.bind(this);
|
||||
this.querySvgElementsByTopicId = this.querySvgElementsByTopicId.bind(this);
|
||||
this.rightClickListener = this.rightClickListener.bind(this);
|
||||
this.isTopicDone = this.isTopicDone.bind(this);
|
||||
|
||||
this.init = this.init.bind(this);
|
||||
@@ -63,20 +62,6 @@ export class Topic {
|
||||
return document.getElementById(this.overlayId);
|
||||
}
|
||||
|
||||
rightClickListener(e) {
|
||||
const groupId = e.target?.closest('g')?.dataset?.groupId;
|
||||
if (!groupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (this.isTopicDone(groupId)) {
|
||||
this.markAsPending(groupId);
|
||||
} else {
|
||||
this.markAsDone(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
resetDOM(hideOverlay = false) {
|
||||
if (hideOverlay) {
|
||||
this.overlayEl.classList.add('hidden');
|
||||
@@ -99,7 +84,8 @@ export class Topic {
|
||||
|
||||
isTopicDone(topicId) {
|
||||
const normalizedGroup = topicId.replace(/^\d+-/, '');
|
||||
return localStorage.getItem(normalizedGroup) === 'done';
|
||||
const el = document.querySelector(`[data-group-id$="-${normalizedGroup}"]`);
|
||||
return el?.classList.contains('done');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,9 +138,9 @@ export class Topic {
|
||||
|
||||
const isDone = localStorage.getItem(topicId) === 'done';
|
||||
if (isDone) {
|
||||
this.markAsPending(topicId);
|
||||
this.markAsPending(topicId, bestPracticeId, 'best-practice');
|
||||
} else {
|
||||
this.markAsDone(topicId);
|
||||
this.markAsDone(topicId, bestPracticeId, 'best-practice');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +151,7 @@ export class Topic {
|
||||
return;
|
||||
}
|
||||
|
||||
this.markAsPending(topicId);
|
||||
this.markAsPending(topicId, bestPracticeId, 'best-practice');
|
||||
}
|
||||
|
||||
handleBestPracticeTopicClick(e) {
|
||||
@@ -244,22 +230,34 @@ export class Topic {
|
||||
return matchingElements;
|
||||
}
|
||||
|
||||
markAsDone(topicId) {
|
||||
async markAsDone(topicId, resourceId, resourceType) {
|
||||
const updatedTopicId = topicId.replace(/^\d+-/, '');
|
||||
localStorage.setItem(updatedTopicId, 'done');
|
||||
|
||||
const { response, error } = {};
|
||||
|
||||
if (response) {
|
||||
this.close();
|
||||
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
|
||||
item?.classList?.add('done');
|
||||
});
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
markAsPending(topicId) {
|
||||
async markAsPending(topicId, resourceId, resourceType) {
|
||||
const updatedTopicId = topicId.replace(/^\d+-/, '');
|
||||
|
||||
localStorage.removeItem(updatedTopicId);
|
||||
const { response, error } = {};
|
||||
|
||||
if (response) {
|
||||
this.close();
|
||||
this.querySvgElementsByTopicId(updatedTopicId).forEach((item) => {
|
||||
item?.classList?.remove('done');
|
||||
});
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleOverlayClick(e) {
|
||||
@@ -274,22 +272,32 @@ export class Topic {
|
||||
e.target.id === this.markTopicDoneId ||
|
||||
e.target.closest(`#${this.markTopicDoneId}`);
|
||||
if (isClickedDone) {
|
||||
this.markAsDone(this.activeTopicId);
|
||||
this.close();
|
||||
this.markAsDone(
|
||||
this.activeTopicId,
|
||||
this.activeResourceId,
|
||||
this.activeResourceType
|
||||
);
|
||||
// this.close();
|
||||
}
|
||||
|
||||
const isClickedPending =
|
||||
e.target.id === this.markTopicPendingId ||
|
||||
e.target.closest(`#${this.markTopicPendingId}`);
|
||||
if (isClickedPending) {
|
||||
this.markAsPending(this.activeTopicId);
|
||||
this.close();
|
||||
this.markAsPending(
|
||||
this.activeTopicId,
|
||||
this.activeResourceId,
|
||||
this.activeResourceType
|
||||
);
|
||||
// this.close();
|
||||
}
|
||||
|
||||
const isClickedPopupOpener =
|
||||
e.target.dataset['popup'] || e.target.closest('button[data-popup]');
|
||||
const isClickedClose =
|
||||
e.target.id === this.closeTopicId ||
|
||||
e.target.closest(`#${this.closeTopicId}`);
|
||||
if (isClickedClose) {
|
||||
if (isClickedClose || isClickedPopupOpener) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -308,9 +316,8 @@ export class Topic {
|
||||
'roadmap.topic.click',
|
||||
this.handleRoadmapTopicClick
|
||||
);
|
||||
window.addEventListener('click', this.handleOverlayClick);
|
||||
window.addEventListener('contextmenu', this.rightClickListener);
|
||||
|
||||
window.addEventListener('click', this.handleOverlayClick);
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key.toLowerCase() === 'escape') {
|
||||
this.close();
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../Icon.astro';
|
||||
import Icon from '../AstroIcon.astro';
|
||||
---
|
||||
|
||||
<script src='./topics.js'></script>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import CaptchaFields from './Captcha/CaptchaFields.astro';
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<div class='my-0 px-5 rounded-lg text-left sm:text-center sm:pb-10 pb-8'>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import Icon from './AstroIcon.astro';
|
||||
---
|
||||
|
||||
<!-- sticky top-0 -->
|
||||
|
1
src/env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
GITHUB_SHA: string;
|
||||
PUBLIC_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
16
src/hooks/use-keydown.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export function useKeydown(keyName: string, callback: any) {
|
||||
useEffect(() => {
|
||||
const listener = (event: any) => {
|
||||
if (event.key.toLowerCase() === keyName.toLowerCase()) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', listener);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', listener);
|
||||
};
|
||||
}, []);
|
||||
}
|
30
src/hooks/use-load-topic.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { ResourceType } from '../lib/resource-progress';
|
||||
|
||||
type CallbackType = (data: {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
topicId: string;
|
||||
}) => void;
|
||||
|
||||
export function useLoadTopic(callback: CallbackType) {
|
||||
useEffect(() => {
|
||||
function handleTopicClick(e: any) {
|
||||
const { resourceType, resourceId, topicId } = e.detail;
|
||||
|
||||
callback({
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener(`roadmap.topic.click`, handleTopicClick);
|
||||
window.addEventListener(`best-practice.topic.click`, handleTopicClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(`roadmap.topic.click`, handleTopicClick);
|
||||
window.removeEventListener(`best-practice.topic.click`, handleTopicClick);
|
||||
};
|
||||
}, []);
|
||||
}
|
20
src/hooks/use-outside-click.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export function useOutsideClick(ref: any, callback: any) {
|
||||
useEffect(() => {
|
||||
const listener = (event: any) => {
|
||||
const isClickedOutside = !ref?.current?.contains(event.target);
|
||||
if (isClickedOutside) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', listener);
|
||||
document.addEventListener('touchstart', listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', listener);
|
||||
document.removeEventListener('touchstart', listener);
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
30
src/hooks/use-toggle-topic.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { ResourceType } from '../lib/resource-progress';
|
||||
|
||||
type CallbackType = (data: {
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
topicId: string;
|
||||
}) => void;
|
||||
|
||||
export function useToggleTopic(callback: CallbackType) {
|
||||
useEffect(() => {
|
||||
function handleToggleTopic(e: any) {
|
||||
const { resourceType, resourceId, topicId } = e.detail;
|
||||
|
||||
callback({
|
||||
resourceType,
|
||||
resourceId,
|
||||
topicId,
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener(`best-practice.topic.toggle`, handleToggleTopic);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
`best-practice.topic.toggle`,
|
||||
handleToggleTopic
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}
|
@@ -1,5 +1,3 @@
|
||||
<svg viewBox="0 0 14 14" focusable="false" class="h-3 w-3" aria-hidden="true">
|
||||
<g fill="currentColor">
|
||||
<polygon points="5.5 11.9993304 14 3.49933039 12.5 2 5.5 8.99933039 1.5 4.9968652 0 6.49933039"></polygon>
|
||||
</g>
|
||||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.5 9.99933L14 1.49933L12.5 0L5.5 6.99933L1.5 2.99687L0 4.49933L5.5 9.99933Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 208 B |
3
src/icons/chevron-down.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
After Width: | Height: | Size: 227 B |
3
src/icons/dropdown.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
After Width: | Height: | Size: 227 B |
18
src/icons/error.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="512px" height="512px">
|
||||
<linearGradient id="wRKXFJsqHCxLE9yyOYHkza" x1="9.858" x2="38.142" y1="9.858" y2="38.142"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#f44f5a"/>
|
||||
<stop offset=".443" stop-color="#ee3d4a"/>
|
||||
<stop offset="1" stop-color="#e52030"/>
|
||||
</linearGradient>
|
||||
<path fill="url(#wRKXFJsqHCxLE9yyOYHkza)"
|
||||
d="M44,24c0,11.045-8.955,20-20,20S4,35.045,4,24S12.955,4,24,4S44,12.955,44,24z"/>
|
||||
<path d="M33.192,28.95L28.243,24l4.95-4.95c0.781-0.781,0.781-2.047,0-2.828l-1.414-1.414 c-0.781-0.781-2.047-0.781-2.828,0L24,19.757l-4.95-4.95c-0.781-0.781-2.047-0.781-2.828,0l-1.414,1.414 c-0.781,0.781-0.781,2.047,0,2.828l4.95,4.95l-4.95,4.95c-0.781,0.781-0.781,2.047,0,2.828l1.414,1.414 c0.781,0.781,2.047,0.781,2.828,0l4.95-4.95l4.95,4.95c0.781,0.781,2.047,0.781,2.828,0l1.414-1.414 C33.973,30.997,33.973,29.731,33.192,28.95z"
|
||||
opacity=".05"/>
|
||||
<path d="M32.839,29.303L27.536,24l5.303-5.303c0.586-0.586,0.586-1.536,0-2.121l-1.414-1.414 c-0.586-0.586-1.536-0.586-2.121,0L24,20.464l-5.303-5.303c-0.586-0.586-1.536-0.586-2.121,0l-1.414,1.414 c-0.586,0.586-0.586,1.536,0,2.121L20.464,24l-5.303,5.303c-0.586,0.586-0.586,1.536,0,2.121l1.414,1.414 c0.586,0.586,1.536,0.586,2.121,0L24,27.536l5.303,5.303c0.586,0.586,1.536,0.586,2.121,0l1.414-1.414 C33.425,30.839,33.425,29.889,32.839,29.303z"
|
||||
opacity=".07"/>
|
||||
<path fill="#fff"
|
||||
d="M31.071,15.515l1.414,1.414c0.391,0.391,0.391,1.024,0,1.414L18.343,32.485 c-0.391,0.391-1.024,0.391-1.414,0l-1.414-1.414c-0.391-0.391-0.391-1.024,0-1.414l14.142-14.142 C30.047,15.124,30.681,15.124,31.071,15.515z"/>
|
||||
<path fill="#fff"
|
||||
d="M32.485,31.071l-1.414,1.414c-0.391,0.391-1.024,0.391-1.414,0L15.515,18.343 c-0.391-0.391-0.391-1.024,0-1.414l1.414-1.414c0.391-0.391,1.024-0.391,1.414,0l14.142,14.142 C32.876,30.047,32.876,30.681,32.485,31.071z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
1
src/icons/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" xmlns:v="https://vecta.io/nano"><path fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362l-.08-9.127c-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126l-.08 13.526c0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
After Width: | Height: | Size: 941 B |
1
src/icons/google.svg
Normal file
@@ -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">
|
||||
<g fill="currentColor">
|
||||
<path d="M10.319,4.936a7.239,7.239,0,0,1,7.1,2.252,1.25,1.25,0,1,0,1.872-1.657A9.737,9.737,0,0,0,9.743,2.5,10.269,10.269,0,0,0,2.378,9.61a.249.249,0,0,1-.271.178l-1.033-.13A.491.491,0,0,0,.6,9.877a.5.5,0,0,0-.019.526l2.476,4.342a.5.5,0,0,0,.373.248.43.43,0,0,0,.062,0,.5.5,0,0,0,.359-.152l3.477-3.593a.5.5,0,0,0-.3-.844L5.15,10.172a.25.25,0,0,1-.2-.333A7.7,7.7,0,0,1,10.319,4.936Z"></path>
|
||||
<path d="M23.406,14.1a.5.5,0,0,0,.015-.526l-2.5-4.329A.5.5,0,0,0,20.546,9a.489.489,0,0,0-.421.151l-3.456,3.614a.5.5,0,0,0,.3.842l1.848.221a.249.249,0,0,1,.183.117.253.253,0,0,1,.023.216,7.688,7.688,0,0,1-5.369,4.9,7.243,7.243,0,0,1-7.1-2.253,1.25,1.25,0,1,0-1.872,1.656,9.74,9.74,0,0,0,9.549,3.03,10.261,10.261,0,0,0,7.369-7.12.251.251,0,0,1,.27-.179l1.058.127a.422.422,0,0,0,.06,0A.5.5,0,0,0,23.406,14.1Z"></path>
|
||||
</g>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.3193 4.93528C11.5957 4.63203 12.9306 4.68137 14.1811 5.07803C15.4317 5.47469 16.551 6.20375 17.4193 7.18728C17.639 7.43552 17.9483 7.58631 18.2793 7.60647C18.6102 7.62663 18.9355 7.51451 19.1838 7.29478C19.432 7.07505 19.5828 6.7657 19.603 6.43479C19.6231 6.10389 19.511 5.77852 19.2913 5.53028C18.1237 4.20738 16.6187 3.22659 14.9369 2.69273C13.2552 2.15887 11.46 2.092 9.74327 2.49928C8.00102 2.9367 6.404 3.82349 5.11164 5.07111C3.81927 6.31873 2.87678 7.88352 2.37827 9.60928C2.36179 9.66642 2.32541 9.71578 2.27571 9.74843C2.226 9.78108 2.16625 9.79486 2.10727 9.78728L1.07427 9.65728C0.982658 9.64551 0.889587 9.65981 0.805742 9.69855C0.721897 9.73729 0.650678 9.79889 0.600266 9.87628C0.548506 9.95352 0.519307 10.0437 0.515951 10.1366C0.512595 10.2295 0.535213 10.3215 0.581266 10.4023L3.05727 14.7443C3.09587 14.8118 3.14969 14.8693 3.21444 14.9124C3.27919 14.9554 3.35309 14.9828 3.43027 14.9923C3.45091 14.9938 3.47163 14.9938 3.49227 14.9923C3.55924 14.9923 3.62552 14.9788 3.68719 14.9527C3.74886 14.9266 3.80466 14.8884 3.85127 14.8403L7.32827 11.2473C7.39298 11.1803 7.43773 11.0967 7.45745 11.0057C7.47718 10.9147 7.47111 10.82 7.43993 10.7323C7.40875 10.6445 7.35369 10.5673 7.28096 10.5091C7.20823 10.451 7.12071 10.4144 7.02827 10.4033L5.15027 10.1713C5.11341 10.1661 5.07817 10.1527 5.04714 10.1322C5.01611 10.1116 4.99006 10.0844 4.97089 10.0525C4.95173 10.0205 4.93993 9.98475 4.93636 9.9477C4.93279 9.91065 4.93754 9.87326 4.95027 9.83828C5.37211 8.64203 6.08295 7.56852 7.01961 6.71315C7.95627 5.85779 9.08973 5.24707 10.3193 4.93528Z" fill="white"/>
|
||||
<path d="M23.4056 14.1003C23.4568 14.0226 23.4853 13.9323 23.4879 13.8394C23.4905 13.7465 23.4672 13.6547 23.4206 13.5743L20.9206 9.24526C20.8815 9.17807 20.8272 9.12095 20.7621 9.07841C20.697 9.03588 20.6229 9.00912 20.5456 9.00026C20.4685 8.99013 20.3901 8.99854 20.3168 9.02481C20.2436 9.05107 20.1777 9.09442 20.1246 9.15126L16.6686 12.7653C16.6045 12.8323 16.5602 12.9158 16.5408 13.0065C16.5214 13.0972 16.5277 13.1915 16.5588 13.2788C16.5899 13.3662 16.6447 13.4432 16.7171 13.5012C16.7895 13.5592 16.8766 13.5959 16.9686 13.6073L18.8166 13.8283C18.854 13.8327 18.8898 13.8455 18.9215 13.8658C18.9532 13.886 18.9799 13.9132 18.9996 13.9453C19.0192 13.9773 19.0315 14.0133 19.0355 14.0507C19.0394 14.088 19.0351 14.1258 19.0226 14.1613C18.6013 15.3575 17.8906 16.4309 16.9538 17.2859C16.017 18.1408 14.8833 18.7507 13.6536 19.0613C12.3771 19.3639 11.0423 19.3142 9.79178 18.9174C8.54129 18.5206 7.42206 17.7916 6.55361 16.8083C6.44613 16.681 6.31431 16.5765 6.16589 16.5009C6.01746 16.4253 5.85543 16.3802 5.68931 16.3681C5.52318 16.356 5.35632 16.3773 5.19852 16.4306C5.04072 16.4839 4.89517 16.5682 4.77042 16.6786C4.64566 16.7889 4.54422 16.9231 4.47205 17.0732C4.39989 17.2233 4.35845 17.3864 4.35018 17.5527C4.3419 17.7191 4.36696 17.8854 4.42388 18.0419C4.48079 18.1985 4.56842 18.3421 4.68161 18.4643C5.84954 19.7869 7.35483 20.7674 9.03668 21.3011C10.7185 21.8347 12.5138 21.9015 14.2306 21.4943C15.9751 21.0573 17.5742 20.1696 18.8675 18.9199C20.1608 17.6703 21.103 16.1027 21.5996 14.3743C21.6162 14.3173 21.6524 14.2681 21.7019 14.2354C21.7513 14.2026 21.8107 14.1884 21.8696 14.1953L22.9276 14.3223C22.9476 14.3237 22.9676 14.3237 22.9876 14.3223C23.0702 14.3227 23.1516 14.3026 23.2245 14.2638C23.2975 14.2251 23.3597 14.1689 23.4056 14.1003Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 3.4 KiB |
@@ -1,4 +1,4 @@
|
||||
<svg class='h-6 w-6 sm:w-12 sm:h-12 text-gray-200 animate-spin fill-blue-600' viewBox="0 0 93 93" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z" fill="currentColor"/>
|
||||
<path d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z" fill="currentFill"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z" fill="#e5e7eb"/>
|
||||
<path d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z" fill="#2463eb" />
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
1
src/icons/verify-letter.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="512px" height="512px"><path fill="#f79219" d="M222.58,114.782c0-8.69-3.979-16.901-10.8-22.286l-69.526-54.889c-8.357-6.598-20.15-6.598-28.508,0 L44.22,92.496c-6.82,5.385-10.8,13.596-10.8,22.286v12.732H222.58V114.782z"/><path fill="#ffa91a" d="M213.336,223.341H42.664c-5.105,0-9.244-4.138-9.244-9.244V113.116c0-5.105,4.138-9.244,9.244-9.244 h170.672c5.105,0,9.244,4.139,9.244,9.244v100.981C222.58,219.203,218.441,223.341,213.336,223.341z"/><path fill="#f79219" d="M213.336,103.872h-0.756v100.225c0,5.105-4.138,9.244-9.244,9.244H33.42v0.756 c0,5.105,4.138,9.244,9.244,9.244h170.672c5.105,0,9.244-4.138,9.244-9.244V113.116 C222.58,108.011,218.441,103.872,213.336,103.872z"/><path fill="#ef7816" d="M213.336,103.872H42.664c-4.488,0-8.229,3.199-9.067,7.441l79.417,62.697 c8.787,6.937,21.186,6.937,29.973,0l79.417-62.698C221.564,107.071,217.824,103.872,213.336,103.872z"/><path fill="#f1f2f2" d="M203.33,73.49v52.88l-60.34,47.64c-8.789,6.939-21.191,6.939-29.98,0l-60.34-47.64V73.49 c0-4.418,3.582-8,8-8h134.66C199.748,65.49,203.33,69.072,203.33,73.49z"/><g><path fill="#fff" d="M58.67,125.46c-1.101,0-2-0.9-2-2V73.49c0-2.2,1.8-4,4-4h106.89c1.101,0,1.99,0.9,1.99,2s-0.89,2-1.99,2 H60.67v49.97C60.67,124.56,59.77,125.46,58.67,125.46z M175.55,73.49c-1.1,0-2-0.9-2-2s0.9-2,2-2c1.11,0,2,0.9,2,2 S176.66,73.49,175.55,73.49z"/></g><g><path fill="#e6e7e8" d="M195.33,65.49h-2v50.88l-60.34,47.64c-8.789,6.939-21.191,6.939-29.98,0l-50.34-39.745v2.105l60.34,47.64 c8.789,6.939,21.191,6.939,29.98,0l60.34-47.64V73.49C203.33,69.072,199.748,65.49,195.33,65.49z"/></g><g><path fill="#d1d3d4" d="M197.9,65.92c0.274,0.808,0.43,1.67,0.43,2.57v52.88l-60.34,47.64c-8.789,6.939-21.191,6.939-29.98,0 l-55.34-43.692v1.052l60.34,47.64c8.789,6.939,21.191,6.939,29.98,0l60.34-47.64V73.49 C203.33,69.972,201.056,66.991,197.9,65.92z"/></g><g><path fill="#d1d3d4" d="M109.036,99.997H80.422c-1.431,0-2.591-1.16-2.591-2.591v0c0-1.431,1.16-2.591,2.591-2.591h28.614 c1.431,0,2.591,1.16,2.591,2.591v0C111.627,98.836,110.467,99.997,109.036,99.997z"/><path fill="#d1d3d4" d="M175.578,124.03H80.422c-1.431,0-2.591-1.16-2.591-2.591v0c0-1.431,1.16-2.591,2.591-2.591h95.156 c1.431,0,2.591,1.16,2.591,2.591v0C178.169,122.87,177.009,124.03,175.578,124.03z"/><path fill="#d1d3d4" d="M175.578,138.881H80.422c-1.431,0-2.591-1.16-2.591-2.591l0,0c0-1.431,1.16-2.591,2.591-2.591h95.156 c1.431,0,2.591,1.16,2.591,2.591l0,0C178.169,137.721,177.009,138.881,175.578,138.881z"/><polygon fill="#d1d3d4" points="156.425,163.403 99.575,163.403 106.139,168.585 149.861,168.585"/></g><g><polygon fill="#d1d3d4" points="175.236,148.551 80.764,148.551 87.328,153.733 168.672,153.733"/></g></svg>
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,13 +1,14 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
|
||||
import Analytics from '../components/Analytics/Analytics.astro';
|
||||
import Authenticator from '../components/Authenticator/Authenticator.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { PageProgress } from '../components/PageProgress';
|
||||
import Navigation from '../components/Navigation/Navigation.astro';
|
||||
import OpenSourceBanner from '../components/OpenSourceBanner.astro';
|
||||
import type { SponsorType } from '../components/Sponsor/Sponsor.astro';
|
||||
import Sponsor from '../components/Sponsor/Sponsor.astro';
|
||||
import YouTubeBanner from '../components/YouTubeBanner.astro';
|
||||
import { siteConfig } from '../lib/config';
|
||||
import Analytics from '../components/Analytics/Analytics.astro';
|
||||
import '../styles/global.css';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -38,7 +39,9 @@ const currentPageAbsoluteUrl = `https://roadmap.sh${permalink}`;
|
||||
|
||||
const canonicalUrl = givenCanonical || currentPageAbsoluteUrl;
|
||||
|
||||
const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${import.meta.env.GITHUB_SHA}`;
|
||||
const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${
|
||||
import.meta.env.GITHUB_SHA
|
||||
}`;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -51,7 +54,11 @@ const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${i
|
||||
<meta name='description' content={description} />
|
||||
<meta name='author' content='Kamran Ahmed' />
|
||||
<meta name='keywords' content={keywords.join(', ')} />
|
||||
{redirectUrl && <meta http-equiv='refresh' content={`1;url=${redirectUrl}`} />}
|
||||
{
|
||||
redirectUrl && (
|
||||
<meta http-equiv='refresh' content={`1;url=${redirectUrl}`} />
|
||||
)
|
||||
}
|
||||
{noIndex && <meta name='robots' content='noindex' />}
|
||||
<meta
|
||||
name='viewport'
|
||||
@@ -76,23 +83,48 @@ const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${i
|
||||
|
||||
<meta name='mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-capable' content='yes' />
|
||||
<meta name='apple-mobile-web-app-status-bar-style' content='black-translucent' />
|
||||
<meta
|
||||
name='apple-mobile-web-app-status-bar-style'
|
||||
content='black-translucent'
|
||||
/>
|
||||
<meta name='apple-mobile-web-app-title' content='roadmap.sh' />
|
||||
<meta name='application-name' content='roadmap.sh' />
|
||||
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/manifest/apple-touch-icon.png' />
|
||||
<link
|
||||
rel='apple-touch-icon'
|
||||
sizes='180x180'
|
||||
href='/manifest/apple-touch-icon.png'
|
||||
/>
|
||||
<meta name='msapplication-TileColor' content='#101010' />
|
||||
<meta name='theme-color' content='#848a9a' />
|
||||
|
||||
<link rel='manifest' href='/manifest/manifest.json' />
|
||||
<link rel='icon' type='image/png' sizes='32x32' href='/manifest/icon32.png' />
|
||||
<link rel='icon' type='image/png' sizes='16x16' href='/manifest/icon16.png' />
|
||||
<link rel='shortcut icon' href='/manifest/favicon.ico' type='image/x-icon' />
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='32x32'
|
||||
href='/manifest/icon32.png'
|
||||
/>
|
||||
<link
|
||||
rel='icon'
|
||||
type='image/png'
|
||||
sizes='16x16'
|
||||
href='/manifest/icon16.png'
|
||||
/>
|
||||
<link
|
||||
rel='shortcut icon'
|
||||
href='/manifest/favicon.ico'
|
||||
type='image/x-icon'
|
||||
/>
|
||||
|
||||
<link rel='icon' href='/manifest/favicon.ico' type='image/x-icon' />
|
||||
|
||||
<slot name='after-header' />
|
||||
{jsonLd.length > 0 && <script type='application/ld+json' set:html={JSON.stringify(jsonLd)} />}
|
||||
{
|
||||
jsonLd.length > 0 && (
|
||||
<script type='application/ld+json' set:html={JSON.stringify(jsonLd)} />
|
||||
)
|
||||
}
|
||||
</head>
|
||||
<body>
|
||||
<slot name='page-header'>
|
||||
@@ -105,8 +137,12 @@ const commitUrl = `https://github.com/kamranahmedse/developer-roadmap/commit/${i
|
||||
<OpenSourceBanner />
|
||||
<Footer />
|
||||
{sponsor && <Sponsor sponsor={sponsor} />}
|
||||
<slot name='after-footer' />
|
||||
<Analytics />
|
||||
</slot>
|
||||
|
||||
<Analytics />
|
||||
<Authenticator />
|
||||
<PageProgress client:load />
|
||||
|
||||
<slot name='after-footer' />
|
||||
</body>
|
||||
</html>
|
||||
|
12
src/layouts/SettingLayout.astro
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import BaseLayout,{ Props as BaseLayoutProps } from './BaseLayout.astro';
|
||||
|
||||
export interface Props extends BaseLayoutProps {}
|
||||
|
||||
const props = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout {...props}>
|
||||
<slot />
|
||||
<div slot='page-footer'></div>
|
||||
</BaseLayout>
|
153
src/lib/http.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import fp from '@fingerprintjs/fingerprintjs';
|
||||
import { TOKEN_COOKIE_NAME } from './jwt';
|
||||
|
||||
type HttpOptionsType = RequestInit | { headers: Record<string, any> };
|
||||
|
||||
type AppResponse = Record<string, any>;
|
||||
type FetchError = {
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
type AppError = {
|
||||
status: number;
|
||||
message: string;
|
||||
errors?: { message: string; location: string }[];
|
||||
};
|
||||
|
||||
type ApiReturn<ResponseType, ErrorType> = {
|
||||
response?: ResponseType;
|
||||
error?: ErrorType | FetchError;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around fetch to make it easy to handle errors
|
||||
*
|
||||
* @param url
|
||||
* @param options
|
||||
*/
|
||||
export async function httpCall<
|
||||
ResponseType = AppResponse,
|
||||
ErrorType = AppError
|
||||
>(
|
||||
url: string,
|
||||
options?: HttpOptionsType
|
||||
): Promise<ApiReturn<ResponseType, ErrorType>> {
|
||||
try {
|
||||
const fingerprintPromise = await fp.load({ monitoring: false });
|
||||
const fingerprint = await fingerprintPromise.get();
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`,
|
||||
'fp': fingerprint.visitorId,
|
||||
...(options?.headers ?? {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html';
|
||||
|
||||
const data = doesAcceptHtml ? await response.text() : await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
response: data as ResponseType,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Logout user if token is invalid
|
||||
if (data.status === 401) {
|
||||
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
return {
|
||||
response: undefined,
|
||||
error: data as ErrorType,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
response: undefined,
|
||||
error: {
|
||||
status: 0,
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function httpPost<
|
||||
ResponseType = AppResponse,
|
||||
ErrorType = AppError
|
||||
>(
|
||||
url: string,
|
||||
body: Record<string, any>,
|
||||
options?: HttpOptionsType
|
||||
): Promise<ApiReturn<ResponseType, ErrorType>> {
|
||||
return httpCall<ResponseType, ErrorType>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function httpGet<ResponseType = AppResponse, ErrorType = AppError>(
|
||||
url: string,
|
||||
queryParams?: Record<string, any>,
|
||||
options?: HttpOptionsType
|
||||
): Promise<ApiReturn<ResponseType, ErrorType>> {
|
||||
const searchParams = new URLSearchParams(queryParams).toString();
|
||||
const queryUrl = searchParams ? `${url}?${searchParams}` : url;
|
||||
|
||||
return httpCall<ResponseType, ErrorType>(queryUrl, {
|
||||
credentials: 'include',
|
||||
method: 'GET',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export async function httpPatch<
|
||||
ResponseType = AppResponse,
|
||||
ErrorType = AppError
|
||||
>(
|
||||
url: string,
|
||||
body: Record<string, any>,
|
||||
options?: HttpOptionsType
|
||||
): Promise<ApiReturn<ResponseType, ErrorType>> {
|
||||
return httpCall<ResponseType, ErrorType>(url, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function httpPut<ResponseType = AppResponse, ErrorType = AppError>(
|
||||
url: string,
|
||||
body: Record<string, any>,
|
||||
options?: HttpOptionsType
|
||||
): Promise<ApiReturn<ResponseType, ErrorType>> {
|
||||
return httpCall<ResponseType, ErrorType>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function httpDelete<
|
||||
ResponseType = AppResponse,
|
||||
ErrorType = AppError
|
||||
>(
|
||||
url: string,
|
||||
options?: HttpOptionsType
|
||||
): Promise<ApiReturn<ResponseType, ErrorType>> {
|
||||
return httpCall<ResponseType, ErrorType>(url, {
|
||||
...options,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
22
src/lib/jwt.ts
Normal file
@@ -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;
|
||||
}
|
163
src/lib/resource-progress.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { httpGet, httpPatch } from './http';
|
||||
import Cookies from 'js-cookie';
|
||||
import { TOKEN_COOKIE_NAME } from './jwt';
|
||||
import Element = astroHTML.JSX.Element;
|
||||
|
||||
export type ResourceType = 'roadmap' | 'best-practice';
|
||||
|
||||
type TopicMeta = {
|
||||
topicId: string;
|
||||
resourceType: ResourceType;
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
export async function isTopicDone(topic: TopicMeta): Promise<boolean> {
|
||||
const { topicId, resourceType, resourceId } = topic;
|
||||
const doneItems = await getResourceProgress(resourceType, resourceId);
|
||||
|
||||
if (!doneItems) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return doneItems.includes(topicId);
|
||||
}
|
||||
|
||||
export async function toggleMarkTopicDone(
|
||||
topic: TopicMeta,
|
||||
isDone: boolean
|
||||
): Promise<boolean> {
|
||||
const { topicId, resourceType, resourceId } = topic;
|
||||
|
||||
const { response, error } = await httpPatch<{ done: string[] }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`,
|
||||
{
|
||||
topicId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
isDone,
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response?.done) {
|
||||
throw new Error(error?.message || 'Something went wrong');
|
||||
}
|
||||
|
||||
setResourceProgress(resourceType, resourceId, response.done);
|
||||
|
||||
return isDone;
|
||||
}
|
||||
|
||||
export async function getResourceProgress(
|
||||
resourceType: 'roadmap' | 'best-practice',
|
||||
resourceId: string
|
||||
): Promise<string[]> {
|
||||
// No need to load progress if user is not logged in
|
||||
if (!Cookies.get(TOKEN_COOKIE_NAME)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const progressKey = `${resourceType}-${resourceId}-progress`;
|
||||
|
||||
const rawProgress = localStorage.getItem(progressKey);
|
||||
const progress = JSON.parse(rawProgress || 'null');
|
||||
|
||||
const progressTimestamp = progress?.timestamp;
|
||||
const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10);
|
||||
const isProgressExpired = diff > 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
if (!progress || isProgressExpired) {
|
||||
return loadFreshProgress(resourceType, resourceId);
|
||||
}
|
||||
|
||||
return progress.done;
|
||||
}
|
||||
|
||||
async function loadFreshProgress(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string
|
||||
) {
|
||||
const { response, error } = await httpGet<{ done: string[] }>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`,
|
||||
{
|
||||
resourceType,
|
||||
resourceId,
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response?.done) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setResourceProgress(resourceType, resourceId, response.done);
|
||||
|
||||
return response.done;
|
||||
}
|
||||
|
||||
export function setResourceProgress(
|
||||
resourceType: 'roadmap' | 'best-practice',
|
||||
resourceId: string,
|
||||
done: string[]
|
||||
): void {
|
||||
localStorage.setItem(
|
||||
`${resourceType}-${resourceId}-progress`,
|
||||
JSON.stringify({
|
||||
done,
|
||||
timestamp: new Date().getTime(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function renderTopicProgress(topicId: string, isDone: boolean) {
|
||||
const matchingElements: Element[] = [];
|
||||
|
||||
// Elements having sort order in the beginning of the group id
|
||||
document
|
||||
.querySelectorAll(`[data-group-id$="-${topicId}"]`)
|
||||
.forEach((element: unknown) => {
|
||||
const foundGroupId =
|
||||
(element as HTMLOrSVGElement)?.dataset?.groupId || '';
|
||||
const validGroupRegex = new RegExp(`^\\d+-${topicId}$`);
|
||||
|
||||
if (validGroupRegex.test(foundGroupId)) {
|
||||
matchingElements.push(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Elements with exact match of the topic id
|
||||
document
|
||||
.querySelectorAll(`[data-group-id="${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
|
||||
// Matching "check:XXXX" box of the topic
|
||||
document
|
||||
.querySelectorAll(`[data-group-id="check:${topicId}"]`)
|
||||
.forEach((element) => {
|
||||
matchingElements.push(element);
|
||||
});
|
||||
|
||||
matchingElements.forEach((element) => {
|
||||
if (isDone) {
|
||||
element.classList.add('done');
|
||||
} else {
|
||||
element.classList.remove('done');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderResourceProgress(
|
||||
resourceType: ResourceType,
|
||||
resourceId: string
|
||||
) {
|
||||
const progress = await getResourceProgress(resourceType, resourceId);
|
||||
|
||||
progress.forEach((topicId) => {
|
||||
renderTopicProgress(topicId, true);
|
||||
});
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import type { MarkdownFileType } from './file';
|
||||
import type { RoadmapFrontmatter } from './roadmap';
|
||||
import type {MarkdownFileType} from './file';
|
||||
import type {RoadmapFrontmatter} from './roadmap';
|
||||
|
||||
// Generates URL from the topic file path e.g.
|
||||
// -> /src/data/roadmaps/vue/content/102-ecosystem/102-ssr/101-nuxt-js.md
|
||||
@@ -47,17 +47,15 @@ function generateBreadcrumbs(
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbs = breadcrumbUrls.map((breadCrumbUrl): BreadcrumbItem => {
|
||||
return breadcrumbUrls.map((breadCrumbUrl): BreadcrumbItem => {
|
||||
const topicFile = topicFiles[breadCrumbUrl];
|
||||
|
||||
const topicFileContent = topicFile?.file;
|
||||
|
||||
const firstHeading = topicFileContent?.getHeadings()?.[0];
|
||||
|
||||
return { title: firstHeading?.text, url: breadCrumbUrl };
|
||||
return {title: firstHeading?.text, url: breadCrumbUrl};
|
||||
});
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
export type BreadcrumbItem = {
|
||||
@@ -123,7 +121,7 @@ export async function getRoadmapTopicFiles(): Promise<
|
||||
const roadmapUrl = `/${roadmapId}`;
|
||||
|
||||
// Breadcrumbs for the file
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
mapping[topicUrl].breadcrumbs = [
|
||||
{
|
||||
title: 'Roadmaps',
|
||||
url: '/roadmaps',
|
||||
@@ -138,8 +136,6 @@ export async function getRoadmapTopicFiles(): Promise<
|
||||
},
|
||||
...generateBreadcrumbs(topicUrl, mapping),
|
||||
];
|
||||
|
||||
mapping[topicUrl].breadcrumbs = breadcrumbs;
|
||||
});
|
||||
|
||||
return mapping;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import Icon from '../components/Icon.astro';
|
||||
import Icon from '../components/AstroIcon.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { getRoadmapIds } from '../lib/roadmap';
|
||||
|
||||
|
@@ -1,15 +1,17 @@
|
||||
---
|
||||
import CaptchaScripts from '../../components/Captcha/CaptchaScripts.astro';
|
||||
import FAQs from '../../components/FAQs/FAQs.astro';
|
||||
import FrameRenderer from '../../components/FrameRenderer/FrameRenderer.astro';
|
||||
import MarkdownFile from '../../components/MarkdownFile.astro';
|
||||
import RelatedRoadmaps from '../../components/RelatedRoadmaps.astro';
|
||||
import RoadmapHeader from '../../components/RoadmapHeader.astro';
|
||||
import ShareIcons from '../../components/ShareIcons/ShareIcons.astro';
|
||||
import TopicOverlay from '../../components/TopicOverlay/TopicOverlay.astro';
|
||||
import UpcomingForm from '../../components/UpcomingForm.astro';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { generateArticleSchema, generateFAQSchema } from '../../lib/jsonld-schema';
|
||||
import { TopicDetail } from '../../components/TopicDetail/TopicDetail';
|
||||
import {
|
||||
generateArticleSchema,
|
||||
generateFAQSchema,
|
||||
} from '../../lib/jsonld-schema';
|
||||
import { getRoadmapIds, RoadmapFrontmatter } from '../../lib/roadmap';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -25,8 +27,12 @@ interface Params extends Record<string, string | undefined> {
|
||||
}
|
||||
|
||||
const { roadmapId } = Astro.params as Params;
|
||||
const roadmapFile = await import(`../../data/roadmaps/${roadmapId}/${roadmapId}.md`);
|
||||
const { faqs: roadmapFAQs = [] } = await import(`../../data/roadmaps/${roadmapId}/faqs.astro`);
|
||||
const roadmapFile = await import(
|
||||
`../../data/roadmaps/${roadmapId}/${roadmapId}.md`
|
||||
);
|
||||
const { faqs: roadmapFAQs = [] } = await import(
|
||||
`../../data/roadmaps/${roadmapId}/faqs.astro`
|
||||
);
|
||||
const roadmapData = roadmapFile.frontmatter as RoadmapFrontmatter;
|
||||
|
||||
let jsonLdSchema = [];
|
||||
@@ -62,7 +68,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
jsonLd={jsonLdSchema}
|
||||
>
|
||||
<!-- Preload the font being used in the renderer -->
|
||||
<link rel='preload' href='/fonts/balsamiq.woff2' as='font' type='font/woff2' crossorigin slot='after-header' />
|
||||
<link
|
||||
rel='preload'
|
||||
href='/fonts/balsamiq.woff2'
|
||||
as='font'
|
||||
type='font/woff2'
|
||||
crossorigin
|
||||
slot='after-header'
|
||||
/>
|
||||
|
||||
<RoadmapHeader
|
||||
title={roadmapData.title}
|
||||
@@ -77,9 +90,12 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
<div class='bg-gray-50 pt-4 sm:pt-12'>
|
||||
{
|
||||
!roadmapData.isUpcoming && roadmapData.jsonUrl && (
|
||||
<div class='max-w-[1000px] container relative'>
|
||||
<ShareIcons description={roadmapData.briefDescription} pageUrl={`https://roadmap.sh/${roadmapId}`} />
|
||||
<TopicOverlay contentContributionLink={contentContributionLink} />
|
||||
<div class='container relative max-w-[1000px]'>
|
||||
<ShareIcons
|
||||
description={roadmapData.briefDescription}
|
||||
pageUrl={`https://roadmap.sh/${roadmapId}`}
|
||||
/>
|
||||
<TopicDetail client:load />
|
||||
|
||||
<FrameRenderer
|
||||
resourceType={'roadmap'}
|
||||
@@ -93,7 +109,7 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
|
||||
{
|
||||
!roadmapData.isUpcoming && !roadmapData.jsonUrl && (
|
||||
<div class='mt-0 sm:-mt-6 pb-14'>
|
||||
<div class='mt-0 pb-14 sm:-mt-6'>
|
||||
<MarkdownFile>
|
||||
<roadmapFile.Content />
|
||||
</MarkdownFile>
|
||||
@@ -105,7 +121,5 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
|
||||
<FAQs faqs={roadmapFAQs} />
|
||||
<RelatedRoadmaps roadmap={roadmapData} />
|
||||
|
||||
<CaptchaScripts slot='after-footer' />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
@@ -1,13 +1,15 @@
|
||||
---
|
||||
import { TopicDetail } from '../../../components/TopicDetail/TopicDetail';
|
||||
import BestPracticeHeader from '../../../components/BestPracticeHeader.astro';
|
||||
import CaptchaScripts from '../../../components/Captcha/CaptchaScripts.astro';
|
||||
import FrameRenderer from '../../../components/FrameRenderer/FrameRenderer.astro';
|
||||
import MarkdownFile from '../../../components/MarkdownFile.astro';
|
||||
import ShareIcons from '../../../components/ShareIcons/ShareIcons.astro';
|
||||
import TopicOverlay from '../../../components/TopicOverlay/TopicOverlay.astro';
|
||||
import UpcomingForm from '../../../components/UpcomingForm.astro';
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro';
|
||||
import { BestPracticeFrontmatter, getBestPracticeIds } from '../../../lib/best-pratice';
|
||||
import {
|
||||
BestPracticeFrontmatter,
|
||||
getBestPracticeIds,
|
||||
} from '../../../lib/best-pratice';
|
||||
import { generateArticleSchema } from '../../../lib/jsonld-schema';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -23,8 +25,11 @@ interface Params extends Record<string, string | undefined> {
|
||||
}
|
||||
|
||||
const { bestPracticeId } = Astro.params as Params;
|
||||
const bestPracticeFile = await import(`../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`);
|
||||
const bestPracticeData = bestPracticeFile.frontmatter as BestPracticeFrontmatter;
|
||||
const bestPracticeFile = await import(
|
||||
`../../../data/best-practices/${bestPracticeId}/${bestPracticeId}.md`
|
||||
);
|
||||
const bestPracticeData =
|
||||
bestPracticeFile.frontmatter as BestPracticeFrontmatter;
|
||||
|
||||
let jsonLdSchema = [];
|
||||
|
||||
@@ -55,7 +60,14 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
jsonLd={jsonLdSchema}
|
||||
>
|
||||
<!-- Preload the font being used in the renderer -->
|
||||
<link rel='preload' href='/fonts/balsamiq.woff2' as='font' type='font/woff2' crossorigin slot='after-header' />
|
||||
<link
|
||||
rel='preload'
|
||||
href='/fonts/balsamiq.woff2'
|
||||
as='font'
|
||||
type='font/woff2'
|
||||
crossorigin
|
||||
slot='after-header'
|
||||
/>
|
||||
|
||||
<BestPracticeHeader
|
||||
title={bestPracticeData.title}
|
||||
@@ -67,12 +79,13 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
<div class='bg-gray-50 py-4 sm:py-12'>
|
||||
{
|
||||
!bestPracticeData.isUpcoming && bestPracticeData.jsonUrl && (
|
||||
<div class='max-w-[1000px] container relative'>
|
||||
<div class='container relative max-w-[1000px]'>
|
||||
<ShareIcons
|
||||
description={bestPracticeData.briefDescription}
|
||||
pageUrl={`https://roadmap.sh/best-practices/${bestPracticeId}`}
|
||||
/>
|
||||
<TopicOverlay contentContributionLink={contentContributionLink} />
|
||||
|
||||
<TopicDetail client:load />
|
||||
|
||||
<FrameRenderer
|
||||
resourceType={'best-practice'}
|
||||
@@ -94,5 +107,4 @@ const contentContributionLink = `https://github.com/kamranahmedse/developer-road
|
||||
</div>
|
||||
|
||||
{bestPracticeData.isUpcoming && <UpcomingForm />}
|
||||
<CaptchaScripts slot='after-footer' />
|
||||
</BaseLayout>
|
||||
|
32
src/pages/forgot-password.astro
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import { ForgotPasswordForm } from '../components/AuthenticationFlow/ForgotPasswordForm';
|
||||
import SettingLayout from '../layouts/SettingLayout.astro';
|
||||
---
|
||||
|
||||
<SettingLayout title='Forgot Password'>
|
||||
<div class='container'>
|
||||
<div
|
||||
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'
|
||||
>
|
||||
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
|
||||
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>
|
||||
Forgot Password?
|
||||
</h1>
|
||||
<p class='mb-3 text-base leading-6 text-gray-600'>
|
||||
Enter your email address below and we will send you a link to reset
|
||||
your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ForgotPasswordForm client:load />
|
||||
|
||||
<div class='mt-6 text-center text-sm'>
|
||||
Don't have an account? <a
|
||||
href='/signup'
|
||||
class='font-medium text-blue-600 transition duration-150 ease-in-out hover:text-blue-500'
|
||||
>Sign up</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingLayout>
|
41
src/pages/login.astro
Normal file
@@ -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)
|
23
src/pages/reset-password.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import ResetPasswordForm from '../components/AuthenticationFlow/ResetPasswordForm';
|
||||
import SettingLayout from '../layouts/SettingLayout.astro';
|
||||
---
|
||||
|
||||
<SettingLayout title='Reset Password'>
|
||||
<div class='container'>
|
||||
<div
|
||||
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'
|
||||
>
|
||||
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
|
||||
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>
|
||||
Reset Password
|
||||
</h1>
|
||||
<p class='mb-3 text-base leading-6 text-gray-600'>
|
||||
Enter and confirm your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ResetPasswordForm client:load />
|
||||
</div>
|
||||
</div>
|
||||
</SettingLayout>
|
11
src/pages/settings/update-password.astro
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import UpdatePasswordForm from '../../components/Setting/UpdatePasswordForm';
|
||||
import SettingSidebar from '../../components/Setting/SettingSidebar.astro';
|
||||
import SettingLayout from '../../layouts/SettingLayout.astro';
|
||||
---
|
||||
|
||||
<SettingLayout title='Change Password' description=''>
|
||||
<SettingSidebar pageUrl='change-password' name='Change Password'>
|
||||
<UpdatePasswordForm client:load />
|
||||
</SettingSidebar>
|
||||
</SettingLayout>
|
11
src/pages/settings/update-profile.astro
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
import SettingSidebar from '../../components/Setting/SettingSidebar.astro';
|
||||
import { UpdateProfileForm } from '../../components/Setting/UpdateProfileForm';
|
||||
import SettingLayout from '../../layouts/SettingLayout.astro';
|
||||
---
|
||||
|
||||
<SettingLayout title='Update Profile'>
|
||||
<SettingSidebar pageUrl='profile' name='Profile'>
|
||||
<UpdateProfileForm client:load />
|
||||
</SettingSidebar>
|
||||
</SettingLayout>
|
@@ -1,63 +1,49 @@
|
||||
---
|
||||
import CaptchaFields from '../components/Captcha/CaptchaFields.astro';
|
||||
import CaptchaScripts from '../components/Captcha/CaptchaScripts.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Divider from '../components/AuthenticationFlow/Divider.astro';
|
||||
import GoogleLogin from '../components/Login/GoogleLogin.astro';
|
||||
import EmailSignupForm from '../components/AuthenticationFlow/EmailSignupForm';
|
||||
import SettingLayout from '../layouts/SettingLayout.astro';
|
||||
import { GitHubButton } from '../components/AuthenticationFlow/GitHubButton';
|
||||
import { GoogleButton } from '../components/AuthenticationFlow/GoogleButton';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
<SettingLayout
|
||||
title='Signup - roadmap.sh'
|
||||
description='Register yourself to receive occasional emails about new roadmaps, updates, guides and videos'
|
||||
description='Create an account to track your progress, showcase your skillset'
|
||||
permalink={'/signup'}
|
||||
noIndex={true}
|
||||
>
|
||||
<div class='container'>
|
||||
<div
|
||||
class='py-12 sm:py-0 sm:min-h-[550px] sm:max-w-[400px] mx-auto flex items-start sm:items-center flex-col justify-start sm:justify-center'
|
||||
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'
|
||||
>
|
||||
<div class='mb-2 sm:mb-5 text-left sm:text-center'>
|
||||
<h1 class='text-3xl sm:text-5xl font-semibold mb-2 sm:mb-4'>Signup</h1>
|
||||
<p class='hidden sm:block text-md text-gray-600'>
|
||||
Register yourself to receive occasional emails about new roadmaps, updates, guides and videos
|
||||
<div class='mb-2 text-left sm:mb-5 sm:text-center'>
|
||||
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>Sign Up</h1>
|
||||
<p class='mb-3 hidden text-base leading-6 text-gray-600 sm:block'>
|
||||
Create an account to track your progress, showcase your skill-set and
|
||||
be a part of the community.
|
||||
</p>
|
||||
<p class='text-sm block sm:hidden text-gray-600'>
|
||||
Register yourself for occasional updates about roadmaps, guides and videos.
|
||||
<p class='mb-3 block text-sm text-gray-600 sm:hidden'>
|
||||
Create an account to track your progress, showcase your skill-set and
|
||||
be a part of the community. videos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
action='https://news.roadmap.sh/subscribe'
|
||||
method='POST'
|
||||
accept-charset='utf-8'
|
||||
class='w-full'
|
||||
captcha-form
|
||||
>
|
||||
<input type='hidden' name='gdpr' value='true' />
|
||||
|
||||
<input
|
||||
type='email'
|
||||
required
|
||||
name='email'
|
||||
id='email'
|
||||
autofocus
|
||||
class='mt-1 block w-full mb-2 border-2 rounded-md py-2 sm:py-3 px-3 sm:px-3.5 text-md'
|
||||
placeholder='Enter your email'
|
||||
/>
|
||||
|
||||
<CaptchaFields />
|
||||
|
||||
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' />
|
||||
<input type='hidden' name='subform' value='yes' />
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
name='submit'
|
||||
class='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'
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class='flex w-full flex-col items-stretch gap-2'>
|
||||
<GitHubButton client:load />
|
||||
<GoogleButton client:load />
|
||||
</div>
|
||||
|
||||
<CaptchaScripts slot='after-footer' />
|
||||
</BaseLayout>
|
||||
<Divider />
|
||||
|
||||
<EmailSignupForm client:load />
|
||||
|
||||
<div class='mt-6 text-center text-sm text-slate-600'>
|
||||
Already have an account? <a
|
||||
href='/login'
|
||||
class='font-medium text-blue-700 hover:text-blue-600'>Login</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingLayout>
|
||||
|
10
src/pages/verification-pending.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import SettingLayout from '../layouts/SettingLayout.astro';
|
||||
import { VerificationEmailMessage } from '../components/AuthenticationFlow/VerificationEmailMessage';
|
||||
---
|
||||
|
||||
<SettingLayout title='Verify Email'>
|
||||
<section class='container py-8 sm:py-20'>
|
||||
<VerificationEmailMessage client:load />
|
||||
</section>
|
||||
</SettingLayout>
|
10
src/pages/verify-account.astro
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
import { TriggerVerifyAccount } from '../components/AuthenticationFlow/TriggerVerifyAccount';
|
||||
import SettingLayout from '../layouts/SettingLayout.astro';
|
||||
---
|
||||
|
||||
<SettingLayout title='Verify account'>
|
||||
<div class='container py-16'>
|
||||
<TriggerVerifyAccount client:load />
|
||||
</div>
|
||||
</SettingLayout>
|
3
src/stores/page.ts
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
|