feat: user accounts functionality (#3813)
* feat: integrate astro * chore: login popup design * chore: data-popup changed * refactor: github and google button * chore: signup page * chore: login popup design * chore: signup page design * chore: auth divider * feat: integrate astro * chore: login popup design * chore: data-popup changed * refactor: github and google button * chore: signup page * chore: login popup design * chore: signup page design * chore: auth divider * chore: login feature * chore: login error message * chore: added name in token decode return * chore: use auth hook * chore: logout vs login * chore: download button link * chore: account dropdown * fix: dropdown z index * chore: profile page * Add missing content for backend roadmap * Remove unused styles * Add login with google * chore: google login implementation * chore: profile guard clause * fix: button size * chore: preact to astro components * chore: preact to astro comp * chore: github astro component * chore: google login error handling * chore: github login error handling * chore: change password page * chore: rename profile to password * fix: change password rename * chore: update profile page * chore: setting sidebar * fix: setting dropdown design * chore: required indicator * chore: change password form * chore: update profile form * chore: mobile navigation * fix: form data empty error * chore: email login and signup components * chore: forgot password page * chore: reset password page * chore: verify account page * chore: resend verification email * fix: types in spinner * chore: forgot password functionality * fix: class -> className * chore: reset password page * chore: reset password functionality * chore: login page * fix: spacing for login and signup page * refactor: email login form * chore: astro spinner * chore: pre-fill user data * chore: dummy placeholder * chore: forgot password link add * fix: replaced constants * chore: forgot password link * chore: change password for social provider * chore: internal pages guard * chore: internal paths * refactor: change password errors * refactor: update profile errors * chore: mark as done overlay * fix: uncontrolled to controlled form * fix: de-structure error * chore: error messages * fix: 401 error code redirect to login page * chore: loading spinner accessibilities * fix: remove spinner * chore: keep spinner after success to redirect * chore: keep the spinner * style: resend email underline * chore: chevron down account * chore: roadmap pdf link download * chore: roadmap pdf link download * chore: best practices buttons * fix: verify account text * fix: topic overlay hide * chore: base verify design * chore: email verify page * fix: div tag missing * Formatting * Refactor top navigation * Prettier * Update dependencies * Refactor top navigation * Refactor login button * Remove captcha and add google scripts * Refactor email sign up form * Resend verfication email functionality * Refactor verification pending page * Add verify account functionality * Update signup text * Add login page * Add login button in top nav * Email login form * Handle authenticatoin * Show hide auth elements change * Add ease-in on the guest elements * Refactor logic for download and subscribe popups * Add forgot password * Rename fetch lib * Add authentication popup * Refactor logic for mark done and pending * Handle logout * Add route protection * Popup opener to close the overlay * Remember page when logging in * Add reset password page * Change placement of constant * Update profile page * Add update password form * Update password page * Update profile page * Update design * chore: toggle mark resource done api * chore: toggle topic done * chore: get user resource progress api * fix: best practice topic toggle * chore: fetch progress * fix: query selector for topics * Keep track of the old page before social login * Update public api url * Add user progress tracking * Update topic done functionality * Add progress loader * Add page wide spinner * Add spinner on setting pages * Add fingerprint to user requests * Use http wrapper instead of fetch * Update fingerprint * Minor improvements --------- Co-authored-by: Arik Chakma <arikchangma@gmail.com>pull/3816/head
@ -0,0 +1 @@ |
|||||||
|
PUBLIC_API_URL=http://api.roadmap.sh |
@ -0,0 +1,5 @@ |
|||||||
|
<div class='flex w-full items-center gap-2 py-6 text-sm text-slate-600'> |
||||||
|
<div class='h-px w-full bg-slate-200'></div> |
||||||
|
OR |
||||||
|
<div class='h-px w-full bg-slate-200'></div> |
||||||
|
</div> |
@ -0,0 +1,100 @@ |
|||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import type { FunctionComponent } from 'preact'; |
||||||
|
import { useState } from 'preact/hooks'; |
||||||
|
import { httpPost } from '../../lib/http'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
|
||||||
|
const EmailLoginForm: FunctionComponent<{}> = () => { |
||||||
|
const [email, setEmail] = useState<string>(''); |
||||||
|
const [password, setPassword] = useState<string>(''); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false); |
||||||
|
|
||||||
|
const handleFormSubmit = async (e: Event) => { |
||||||
|
e.preventDefault(); |
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
|
||||||
|
const { response, error } = await httpPost<{ token: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-login`, |
||||||
|
{ |
||||||
|
email, |
||||||
|
password, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
// Log the user in and reload the page
|
||||||
|
if (response?.token) { |
||||||
|
Cookies.set(TOKEN_COOKIE_NAME, response.token); |
||||||
|
window.location.reload(); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// @todo use proper types
|
||||||
|
if ((error as any).type === 'user_not_verified') { |
||||||
|
window.location.href = `/verification-pending?email=${encodeURIComponent( |
||||||
|
email |
||||||
|
)}`;
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
setError(error?.message || 'Something went wrong. Please try again later.'); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<form className="w-full" onSubmit={handleFormSubmit}> |
||||||
|
<label htmlFor="email" className="sr-only"> |
||||||
|
Email address |
||||||
|
</label> |
||||||
|
<input |
||||||
|
name="email" |
||||||
|
type="email" |
||||||
|
autoComplete="email" |
||||||
|
required |
||||||
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="Email Address" |
||||||
|
value={email} |
||||||
|
onInput={(e) => setEmail(String((e.target as any).value))} |
||||||
|
/> |
||||||
|
<label htmlFor="password" className="sr-only"> |
||||||
|
Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
name="password" |
||||||
|
type="password" |
||||||
|
autoComplete="current-password" |
||||||
|
required |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="Password" |
||||||
|
value={password} |
||||||
|
onInput={(e) => setPassword(String((e.target as any).value))} |
||||||
|
/> |
||||||
|
|
||||||
|
<p class="mb-3 mt-2 text-sm text-gray-500"> |
||||||
|
<a |
||||||
|
href="/forgot-password" |
||||||
|
className="text-blue-800 hover:text-blue-600" |
||||||
|
> |
||||||
|
Reset your password? |
||||||
|
</a> |
||||||
|
</p> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p className="mb-2 rounded-md bg-red-100 p-2 text-red-800">{error}</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isLoading} |
||||||
|
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Continue'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default EmailLoginForm; |
@ -0,0 +1,103 @@ |
|||||||
|
import type { FunctionComponent } from 'preact'; |
||||||
|
import { useState } from 'preact/hooks'; |
||||||
|
import { httpPost } from '../../lib/http'; |
||||||
|
|
||||||
|
const EmailSignupForm: FunctionComponent = () => { |
||||||
|
const [email, setEmail] = useState(''); |
||||||
|
const [password, setPassword] = useState(''); |
||||||
|
const [name, setName] = useState(''); |
||||||
|
|
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
|
||||||
|
const onSubmit = async (e: Event) => { |
||||||
|
e.preventDefault(); |
||||||
|
|
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
|
||||||
|
const { response, error } = await httpPost<{ status: 'ok' }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-register`, |
||||||
|
{ |
||||||
|
email, |
||||||
|
password, |
||||||
|
name, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error || response?.status !== 'ok') { |
||||||
|
setIsLoading(false); |
||||||
|
setError( |
||||||
|
error?.message || 'Something went wrong. Please try again later.' |
||||||
|
); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
window.location.href = `/verification-pending?email=${encodeURIComponent( |
||||||
|
email |
||||||
|
)}`;
|
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<form className="flex w-full flex-col gap-2" onSubmit={onSubmit}> |
||||||
|
<label htmlFor="name" className="sr-only"> |
||||||
|
Name |
||||||
|
</label> |
||||||
|
<input |
||||||
|
name="name" |
||||||
|
type="text" |
||||||
|
autoComplete="name" |
||||||
|
min={3} |
||||||
|
max={50} |
||||||
|
required |
||||||
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="Full Name" |
||||||
|
value={name} |
||||||
|
onInput={(e) => setName(String((e.target as any).value))} |
||||||
|
/> |
||||||
|
<label htmlFor="email" className="sr-only"> |
||||||
|
Email address |
||||||
|
</label> |
||||||
|
<input |
||||||
|
name="email" |
||||||
|
type="email" |
||||||
|
autoComplete="email" |
||||||
|
required |
||||||
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="Email Address" |
||||||
|
value={email} |
||||||
|
onInput={(e) => setEmail(String((e.target as any).value))} |
||||||
|
/> |
||||||
|
<label htmlFor="password" className="sr-only"> |
||||||
|
Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
name="password" |
||||||
|
type="password" |
||||||
|
autoComplete="current-password" |
||||||
|
min={6} |
||||||
|
max={50} |
||||||
|
required |
||||||
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="Password" |
||||||
|
value={password} |
||||||
|
onInput={(e) => setPassword(String((e.target as any).value))} |
||||||
|
/> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p className="rounded-lg bg-red-100 p-2 text-red-700">{error}.</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isLoading} |
||||||
|
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Continue to Verify Email'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
export default EmailSignupForm; |
@ -0,0 +1,64 @@ |
|||||||
|
import { useState } from 'preact/hooks'; |
||||||
|
import { httpPost } from '../../lib/http'; |
||||||
|
|
||||||
|
export function ForgotPasswordForm() { |
||||||
|
const [email, setEmail] = useState(''); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [success, setSuccess] = useState(''); |
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => { |
||||||
|
e.preventDefault(); |
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
|
||||||
|
const { response, error } = await httpPost( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-forgot-password`, |
||||||
|
{ |
||||||
|
email, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
if (error) { |
||||||
|
setError(error.message); |
||||||
|
} else { |
||||||
|
setEmail(''); |
||||||
|
setSuccess('Check your email for a link to reset your password.'); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<form onSubmit={handleSubmit} class="w-full"> |
||||||
|
<input |
||||||
|
type="email" |
||||||
|
name="email" |
||||||
|
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
placeholder="Email Address" |
||||||
|
value={email} |
||||||
|
onInput={(e) => setEmail((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p className="mt-2 rounded-lg bg-red-100 p-2 text-sm text-red-700"> |
||||||
|
{error} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
|
||||||
|
{success && ( |
||||||
|
<p className="mt-2 rounded-lg bg-green-100 p-2 text-sm text-green-700"> |
||||||
|
{success} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isLoading} |
||||||
|
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Continue'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,116 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
import GitHubIcon from '../../icons/github.svg'; |
||||||
|
import SpinnerIcon from '../../icons/spinner.svg'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
import { httpGet } from '../../lib/http'; |
||||||
|
|
||||||
|
type GitHubButtonProps = {}; |
||||||
|
|
||||||
|
const GITHUB_REDIRECT_AT = 'githubRedirectAt'; |
||||||
|
const GITHUB_LAST_PAGE = 'githubLastPage'; |
||||||
|
|
||||||
|
export function GitHubButton(props: GitHubButtonProps) { |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const icon = isLoading ? SpinnerIcon : GitHubIcon; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const urlParams = new URLSearchParams(window.location.search); |
||||||
|
const code = urlParams.get('code'); |
||||||
|
const state = urlParams.get('state'); |
||||||
|
const provider = urlParams.get('provider'); |
||||||
|
|
||||||
|
if (!code || !state || provider !== 'github') { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(true); |
||||||
|
httpGet<{ token: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-github-callback${ |
||||||
|
window.location.search |
||||||
|
}` |
||||||
|
) |
||||||
|
.then(({ response, error }) => { |
||||||
|
if (!response?.token) { |
||||||
|
const errMessage = error?.message || 'Something went wrong.'; |
||||||
|
setError(errMessage); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let redirectUrl = '/'; |
||||||
|
const gitHubRedirectAt = localStorage.getItem(GITHUB_REDIRECT_AT); |
||||||
|
const lastPageBeforeGithub = localStorage.getItem(GITHUB_LAST_PAGE); |
||||||
|
|
||||||
|
// If the social redirect is there and less than 30 seconds old
|
||||||
|
// redirect to the page that user was on before they clicked the github login button
|
||||||
|
if (gitHubRedirectAt && lastPageBeforeGithub) { |
||||||
|
const socialRedirectAtTime = parseInt(gitHubRedirectAt, 10); |
||||||
|
const now = Date.now(); |
||||||
|
const timeSinceRedirect = now - socialRedirectAtTime; |
||||||
|
|
||||||
|
if (timeSinceRedirect < 30 * 1000) { |
||||||
|
redirectUrl = lastPageBeforeGithub; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
localStorage.removeItem(GITHUB_REDIRECT_AT); |
||||||
|
localStorage.removeItem(GITHUB_LAST_PAGE); |
||||||
|
Cookies.set(TOKEN_COOKIE_NAME, response.token); |
||||||
|
window.location.href = redirectUrl; |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const handleClick = async () => { |
||||||
|
setIsLoading(true); |
||||||
|
|
||||||
|
const { response, error } = await httpGet<{ loginUrl: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-github-login` |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response?.loginUrl) { |
||||||
|
setError( |
||||||
|
error?.message || 'Something went wrong. Please try again later.' |
||||||
|
); |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// For non authentication pages, we want to redirect back to the page
|
||||||
|
// the user was on before they clicked the social login button
|
||||||
|
if (!['/login', '/signup'].includes(window.location.pathname)) { |
||||||
|
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString()); |
||||||
|
localStorage.setItem(GITHUB_LAST_PAGE, window.location.pathname); |
||||||
|
} |
||||||
|
|
||||||
|
window.location.href = response.loginUrl; |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<button |
||||||
|
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60" |
||||||
|
disabled={isLoading} |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<img |
||||||
|
src={icon} |
||||||
|
alt="GitHub" |
||||||
|
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`} |
||||||
|
/> |
||||||
|
Continue with GitHub |
||||||
|
</button> |
||||||
|
{error && ( |
||||||
|
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,116 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import GoogleIcon from '../../icons/google.svg'; |
||||||
|
import SpinnerIcon from '../../icons/spinner.svg'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
import { httpGet } from '../../lib/http'; |
||||||
|
|
||||||
|
type GoogleButtonProps = {}; |
||||||
|
|
||||||
|
const GOOGLE_REDIRECT_AT = 'googleRedirectAt'; |
||||||
|
const GOOGLE_LAST_PAGE = 'googleLastPage'; |
||||||
|
|
||||||
|
export function GoogleButton(props: GoogleButtonProps) { |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const icon = isLoading ? SpinnerIcon : GoogleIcon; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const urlParams = new URLSearchParams(window.location.search); |
||||||
|
const code = urlParams.get('code'); |
||||||
|
const state = urlParams.get('state'); |
||||||
|
const provider = urlParams.get('provider'); |
||||||
|
|
||||||
|
if (!code || !state || provider !== 'google') { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsLoading(true); |
||||||
|
httpGet<{ token: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-google-callback${ |
||||||
|
window.location.search |
||||||
|
}` |
||||||
|
) |
||||||
|
.then(({ response, error }) => { |
||||||
|
if (!response?.token) { |
||||||
|
setError(error?.message || 'Something went wrong.'); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let redirectUrl = '/'; |
||||||
|
const googleRedirectAt = localStorage.getItem(GOOGLE_REDIRECT_AT); |
||||||
|
const lastPageBeforeGoogle = localStorage.getItem(GOOGLE_LAST_PAGE); |
||||||
|
|
||||||
|
// If the social redirect is there and less than 30 seconds old
|
||||||
|
// redirect to the page that user was on before they clicked the github login button
|
||||||
|
if (googleRedirectAt && lastPageBeforeGoogle) { |
||||||
|
const socialRedirectAtTime = parseInt(googleRedirectAt, 10); |
||||||
|
const now = Date.now(); |
||||||
|
const timeSinceRedirect = now - socialRedirectAtTime; |
||||||
|
|
||||||
|
if (timeSinceRedirect < 30 * 1000) { |
||||||
|
redirectUrl = lastPageBeforeGoogle; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
localStorage.removeItem(GOOGLE_REDIRECT_AT); |
||||||
|
localStorage.removeItem(GOOGLE_LAST_PAGE); |
||||||
|
Cookies.set(TOKEN_COOKIE_NAME, response.token); |
||||||
|
window.location.href = redirectUrl; |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const handleClick = () => { |
||||||
|
setIsLoading(true); |
||||||
|
httpGet<{ loginUrl: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-google-login` |
||||||
|
) |
||||||
|
.then(({ response, error }) => { |
||||||
|
if (!response?.loginUrl) { |
||||||
|
setError(error?.message || 'Something went wrong.'); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// For non authentication pages, we want to redirect back to the page
|
||||||
|
// the user was on before they clicked the social login button
|
||||||
|
if (!['/login', '/signup'].includes(window.location.pathname)) { |
||||||
|
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString()); |
||||||
|
localStorage.setItem(GOOGLE_LAST_PAGE, window.location.pathname); |
||||||
|
} |
||||||
|
|
||||||
|
window.location.href = response.loginUrl; |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<button |
||||||
|
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded border border-slate-300 bg-white p-2 text-sm font-medium text-black outline-none focus:ring-2 focus:ring-[#333] focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-60" |
||||||
|
disabled={isLoading} |
||||||
|
onClick={handleClick} |
||||||
|
> |
||||||
|
<img |
||||||
|
src={icon} |
||||||
|
alt="Google" |
||||||
|
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`} |
||||||
|
/> |
||||||
|
Continue with Google |
||||||
|
</button> |
||||||
|
{error && ( |
||||||
|
<p className="mb-2 mt-1 text-sm font-medium text-red-600">{error}</p> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
--- |
||||||
|
import Popup from '../Popup/Popup.astro'; |
||||||
|
import EmailLoginForm from './EmailLoginForm'; |
||||||
|
import Divider from './Divider.astro'; |
||||||
|
import { GitHubButton } from './GitHubButton'; |
||||||
|
import { GoogleButton } from './GoogleButton'; |
||||||
|
--- |
||||||
|
|
||||||
|
<Popup id='login-popup' title='' subtitle=''> |
||||||
|
<div class='text-center'> |
||||||
|
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'> |
||||||
|
Login to your account |
||||||
|
</h2> |
||||||
|
<p class='mt-2 text-sm leading-4 text-slate-600'> |
||||||
|
You must be logged in to perform this action. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class='mt-7 flex flex-col gap-2'> |
||||||
|
<GitHubButton client:load /> |
||||||
|
<GoogleButton client:load /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Divider /> |
||||||
|
|
||||||
|
<EmailLoginForm client:load /> |
||||||
|
|
||||||
|
<div class='mt-6 text-center text-sm text-slate-600'> |
||||||
|
Don't have an account?{' '} |
||||||
|
<a href='/signup' class='font-medium text-[#4285f4]'> Sign up</a> |
||||||
|
</div> |
||||||
|
</Popup> |
@ -0,0 +1,97 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
import { httpPost } from '../../lib/http'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import {TOKEN_COOKIE_NAME} from "../../lib/jwt"; |
||||||
|
|
||||||
|
export default function ResetPasswordForm() { |
||||||
|
const [code, setCode] = useState(''); |
||||||
|
const [password, setPassword] = useState(''); |
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState(''); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const urlParams = new URLSearchParams(window.location.search); |
||||||
|
const code = urlParams.get('code'); |
||||||
|
|
||||||
|
if (!code) { |
||||||
|
window.location.href = '/login'; |
||||||
|
} else { |
||||||
|
setCode(code); |
||||||
|
} |
||||||
|
}, []); |
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => { |
||||||
|
e.preventDefault(); |
||||||
|
setIsLoading(true); |
||||||
|
|
||||||
|
if (password !== passwordConfirm) { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Passwords do not match.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { response, error } = await httpPost( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-reset-forgotten-password`, |
||||||
|
{ |
||||||
|
newPassword: password, |
||||||
|
confirmPassword: passwordConfirm, |
||||||
|
code, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error?.message) { |
||||||
|
setIsLoading(false); |
||||||
|
setError(error.message); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!response?.token) { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const token = response.token; |
||||||
|
Cookies.set(TOKEN_COOKIE_NAME, token); |
||||||
|
window.location.href = '/'; |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<form className="mx-auto w-full" onSubmit={handleSubmit}> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
className="mb-2 mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
minLength={6} |
||||||
|
placeholder="New Password" |
||||||
|
value={password} |
||||||
|
onInput={(e) => setPassword((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
|
||||||
|
<input |
||||||
|
type="password" |
||||||
|
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none transition duration-150 ease-in-out placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
minLength={6} |
||||||
|
placeholder="Confirm New Password" |
||||||
|
value={passwordConfirm} |
||||||
|
onInput={(e) => |
||||||
|
setPasswordConfirm((e.target as HTMLInputElement).value) |
||||||
|
} |
||||||
|
/> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isLoading} |
||||||
|
className="mt-2 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Reset Password'} |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,79 @@ |
|||||||
|
import SpinnerIcon from '../../icons/spinner.svg'; |
||||||
|
import ErrorIcon from '../../icons/error.svg'; |
||||||
|
|
||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
import { httpPost } from '../../lib/http'; |
||||||
|
|
||||||
|
export function TriggerVerifyAccount() { |
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
|
||||||
|
const triggerVerify = (code: string) => { |
||||||
|
setIsLoading(true); |
||||||
|
|
||||||
|
httpPost<{ token: string }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-verify-account`, |
||||||
|
{ |
||||||
|
code, |
||||||
|
} |
||||||
|
) |
||||||
|
.then(({ response, error }) => { |
||||||
|
if (!response?.token) { |
||||||
|
setError(error?.message || 'Something went wrong. Please try again.'); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
Cookies.set(TOKEN_COOKIE_NAME, response.token); |
||||||
|
window.location.href = '/'; |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Something went wrong. Please try again.'); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const urlParams = new URLSearchParams(window.location.search); |
||||||
|
const code = urlParams.get('code')!; |
||||||
|
|
||||||
|
if (!code) { |
||||||
|
setIsLoading(false); |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
triggerVerify(code); |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12"> |
||||||
|
<div className="mx-auto max-w-md text-center"> |
||||||
|
{isLoading && ( |
||||||
|
<img |
||||||
|
alt={'Please wait.'} |
||||||
|
src={SpinnerIcon} |
||||||
|
class={'mx-auto h-16 w-16 animate-spin'} |
||||||
|
/> |
||||||
|
)} |
||||||
|
{error && ( |
||||||
|
<img |
||||||
|
alt={'Please wait.'} |
||||||
|
src={ErrorIcon} |
||||||
|
className={'mx-auto h-16 w-16'} |
||||||
|
/> |
||||||
|
)} |
||||||
|
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl"> |
||||||
|
Verifying your account |
||||||
|
</h2> |
||||||
|
<div className="text-sm sm:text-base"> |
||||||
|
{isLoading && <p>Please wait while we verify your account..</p>} |
||||||
|
{error && <p class="text-red-700">{error}</p>} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,85 @@ |
|||||||
|
import VerifyLetterIcon from '../../icons/verify-letter.svg'; |
||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
import { httpPost } from '../../lib/http'; |
||||||
|
|
||||||
|
export function VerificationEmailMessage() { |
||||||
|
const [email, setEmail] = useState('..'); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [isEmailResent, setIsEmailResent] = useState(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const urlParams = new URLSearchParams(window.location.search); |
||||||
|
|
||||||
|
setEmail(urlParams.get('email')!); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const resendVerificationEmail = () => { |
||||||
|
httpPost(`${import.meta.env.PUBLIC_API_URL}/v1-send-verification-email`, { |
||||||
|
email, |
||||||
|
}) |
||||||
|
.then(({ response, error }) => { |
||||||
|
if (error) { |
||||||
|
setIsEmailResent(false); |
||||||
|
setError(error?.message || 'Something went wrong.'); |
||||||
|
setIsLoading(false); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsEmailResent(true); |
||||||
|
}) |
||||||
|
.catch(() => { |
||||||
|
setIsEmailResent(false); |
||||||
|
setIsLoading(false); |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="mx-auto max-w-md text-center"> |
||||||
|
<img |
||||||
|
alt="Verify Email" |
||||||
|
src={VerifyLetterIcon} |
||||||
|
class="mx-auto mb-4 h-20 w-40 sm:h-40" |
||||||
|
/> |
||||||
|
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl"> |
||||||
|
Verify your email address |
||||||
|
</h2> |
||||||
|
<div class="text-sm sm:text-base"> |
||||||
|
<p> |
||||||
|
We have sent you an email at{' '} |
||||||
|
<span className="font-bold">{email}</span>. Please click the link to |
||||||
|
verify your account. This link will expire shortly, so please verify |
||||||
|
soon! |
||||||
|
</p> |
||||||
|
|
||||||
|
<hr class="my-4" /> |
||||||
|
|
||||||
|
{!isEmailResent && ( |
||||||
|
<> |
||||||
|
{isLoading && <p className="text-gray-400">Sending the email ..</p>} |
||||||
|
{!isLoading && !error && ( |
||||||
|
<p> |
||||||
|
Please make sure to check your spam folder. If you still don't |
||||||
|
have the email click to{' '} |
||||||
|
<button |
||||||
|
disabled={!email} |
||||||
|
className="inline text-blue-700" |
||||||
|
onClick={resendVerificationEmail} |
||||||
|
> |
||||||
|
resend verification email. |
||||||
|
</button> |
||||||
|
</p> |
||||||
|
)} |
||||||
|
|
||||||
|
{error && <p class="text-red-700">{error}</p>} |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
{isEmailResent && ( |
||||||
|
<p class="text-green-700">Verification email has been sent!</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
--- |
||||||
|
--- |
||||||
|
|
||||||
|
<script src='./authenticator.ts'></script> |
@ -0,0 +1,79 @@ |
|||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
|
||||||
|
function easeInElement(el: Element) { |
||||||
|
el.classList.add('opacity-0', 'transition-opacity', 'duration-300'); |
||||||
|
el.classList.remove('hidden'); |
||||||
|
setTimeout(() => { |
||||||
|
el.classList.remove('opacity-0'); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function showHideAuthElements(hideOrShow: 'hide' | 'show' = 'hide') { |
||||||
|
document.querySelectorAll('[data-auth-required]').forEach((el) => { |
||||||
|
if (hideOrShow === 'hide') { |
||||||
|
el.classList.add('hidden'); |
||||||
|
} else { |
||||||
|
easeInElement(el); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function showHideGuestElements(hideOrShow: 'hide' | 'show' = 'hide') { |
||||||
|
document.querySelectorAll('[data-guest-required]').forEach((el) => { |
||||||
|
if (hideOrShow === 'hide') { |
||||||
|
el.classList.add('hidden'); |
||||||
|
} else { |
||||||
|
easeInElement(el); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Prepares the UI for the user who is logged in
|
||||||
|
function handleGuest() { |
||||||
|
const authenticatedRoutes = [ |
||||||
|
'/settings/update-profile', |
||||||
|
'/settings/update-password', |
||||||
|
]; |
||||||
|
|
||||||
|
showHideAuthElements('hide'); |
||||||
|
showHideGuestElements('show'); |
||||||
|
|
||||||
|
// If the user is on an authenticated route, redirect them to the home page
|
||||||
|
if (authenticatedRoutes.includes(window.location.pathname)) { |
||||||
|
window.location.href = '/'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Prepares the UI for the user who is logged out
|
||||||
|
function handleAuthenticated() { |
||||||
|
const guestRoutes = [ |
||||||
|
'/login', |
||||||
|
'/signup', |
||||||
|
'/verify-account', |
||||||
|
'/verification-pending', |
||||||
|
'/reset-password', |
||||||
|
'/forgot-password', |
||||||
|
]; |
||||||
|
|
||||||
|
showHideGuestElements('hide'); |
||||||
|
showHideAuthElements('show'); |
||||||
|
|
||||||
|
// If the user is on a guest route, redirect them to the home page
|
||||||
|
if (guestRoutes.includes(window.location.pathname)) { |
||||||
|
window.location.href = '/'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function handleAuthRequired() { |
||||||
|
const token = Cookies.get(TOKEN_COOKIE_NAME); |
||||||
|
if (token) { |
||||||
|
handleAuthenticated(); |
||||||
|
} else { |
||||||
|
handleGuest(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
window.setTimeout(() => { |
||||||
|
handleAuthRequired(); |
||||||
|
}, 0); |
@ -1,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,79 +0,0 @@ |
|||||||
--- |
|
||||||
import Icon from './Icon.astro'; |
|
||||||
--- |
|
||||||
|
|
||||||
<div class='bg-slate-900 text-white py-5 sm:py-8'> |
|
||||||
<nav class='container flex items-center justify-between'> |
|
||||||
<a class='font-medium text-lg flex items-center text-white' href='/'> |
|
||||||
<Icon icon='logo' /> |
|
||||||
<span class='ml-3'>roadmap.sh</span> |
|
||||||
</a> |
|
||||||
|
|
||||||
<!-- Desktop navigation items --> |
|
||||||
<ul class='hidden sm:flex space-x-5'> |
|
||||||
<li> |
|
||||||
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/best-practices' class='text-gray-400 hover:text-white'>Best Practices</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a |
|
||||||
class='py-2 px-4 text-sm font-regular rounded-full bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-500 hover:to-blue-600 text-white' |
|
||||||
href='/signup' |
|
||||||
> |
|
||||||
Subscribe |
|
||||||
</a> |
|
||||||
</li> |
|
||||||
</ul> |
|
||||||
|
|
||||||
<!-- Mobile Navigation Button --> |
|
||||||
<button class='text-gray-400 hover:text-gray-50 block sm:hidden cursor-pointer' aria-label='Menu' show-mobile-nav> |
|
||||||
<Icon icon='hamburger' /> |
|
||||||
</button> |
|
||||||
|
|
||||||
<!-- Mobile Navigation Items --> |
|
||||||
<div class='fixed top-0 bottom-0 left-0 right-0 z-40 bg-slate-900 items-center flex hidden' mobile-nav> |
|
||||||
<button |
|
||||||
close-mobile-nav |
|
||||||
class='text-gray-400 hover:text-gray-50 block cursor-pointer absolute top-6 right-6' |
|
||||||
aria-label='Close Menu' |
|
||||||
> |
|
||||||
<Icon icon='close' /> |
|
||||||
</button> |
|
||||||
<ul class='flex flex-col gap-2 md:gap-3 items-center w-full'> |
|
||||||
<li> |
|
||||||
<a href='/roadmaps' class='text-xl md:text-lg hover:text-blue-300'>Roadmaps</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/best-practices' class='text-xl md:text-lg hover:text-blue-300'>Best Practices</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/guides' class='text-xl md:text-lg hover:text-blue-300'>Guides</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/videos' class='text-xl md:text-lg hover:text-blue-300'>Videos</a> |
|
||||||
</li> |
|
||||||
<li> |
|
||||||
<a href='/signup' class='text-xl md:text-lg text-red-300 hover:text-red-400'>Subscribe</a> |
|
||||||
</li> |
|
||||||
</ul> |
|
||||||
</div> |
|
||||||
</nav> |
|
||||||
</div> |
|
||||||
|
|
||||||
<script> |
|
||||||
document.querySelector('[show-mobile-nav]')?.addEventListener('click', () => { |
|
||||||
document.querySelector('[mobile-nav]')?.classList.remove('hidden'); |
|
||||||
}); |
|
||||||
|
|
||||||
document.querySelector('[close-mobile-nav]')?.addEventListener('click', () => { |
|
||||||
document.querySelector('[mobile-nav]')?.classList.add('hidden'); |
|
||||||
}); |
|
||||||
</script> |
|
@ -0,0 +1,44 @@ |
|||||||
|
--- |
||||||
|
import Icon from '../AstroIcon.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<div class='relative hidden' data-auth-required> |
||||||
|
<button |
||||||
|
class='flex h-8 w-28 items-center justify-center rounded-full bg-gradient-to-r from-purple-500 to-purple-700 px-4 py-2 text-sm font-medium text-white hover:from-purple-500 hover:to-purple-600' |
||||||
|
type='button' |
||||||
|
data-account-button |
||||||
|
> |
||||||
|
<span class='inline-flex items-center gap-1.5'> |
||||||
|
Account |
||||||
|
<Icon |
||||||
|
icon='chevron-down' |
||||||
|
class='relative top-[0.5px] h-3 w-3 stroke-[3px]' |
||||||
|
/> |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
|
||||||
|
<div |
||||||
|
class='absolute right-0 z-10 mt-2 hidden w-48 rounded-md bg-slate-800 py-1 shadow-xl' |
||||||
|
data-account-dropdown |
||||||
|
> |
||||||
|
<ul> |
||||||
|
<li class='px-1'> |
||||||
|
<a |
||||||
|
href='/settings/update-profile' |
||||||
|
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700' |
||||||
|
> |
||||||
|
Settings |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li class='px-1'> |
||||||
|
<button |
||||||
|
class='block w-full rounded px-4 py-2 text-left text-sm font-medium text-slate-100 hover:bg-slate-700' |
||||||
|
type='button' |
||||||
|
data-logout-button |
||||||
|
> |
||||||
|
Logout |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</div> |
@ -0,0 +1,133 @@ |
|||||||
|
--- |
||||||
|
import Icon from '../AstroIcon.astro'; |
||||||
|
import AccountDropdown from './AccountDropdown.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<div class='bg-slate-900 py-5 text-white sm:py-8'> |
||||||
|
<nav class='container flex items-center justify-between'> |
||||||
|
<a class='flex items-center text-lg font-medium text-white' href='/'> |
||||||
|
<Icon icon='logo' /> |
||||||
|
<span class='ml-3 hidden md:block'>roadmap.sh</span> |
||||||
|
</a> |
||||||
|
|
||||||
|
<!-- Desktop navigation items --> |
||||||
|
<ul class='hidden space-x-5 sm:flex sm:items-center'> |
||||||
|
<li> |
||||||
|
<a href='/roadmaps' class='text-gray-400 hover:text-white'>Roadmaps</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href='/best-practices' class='text-gray-400 hover:text-white' |
||||||
|
>Best Practices</a |
||||||
|
> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
<ul class='hidden h-8 w-[172px] items-center justify-end gap-5 sm:flex'> |
||||||
|
<li data-guest-required class='hidden'> |
||||||
|
<a href='/login' class='text-gray-400 hover:text-white'>Login</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<AccountDropdown /> |
||||||
|
|
||||||
|
<a |
||||||
|
data-guest-required |
||||||
|
class='flex hidden h-8 w-28 cursor-pointer items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-blue-700 px-4 py-2 text-sm font-medium text-white hover:from-blue-500 hover:to-blue-600' |
||||||
|
href='/signup' |
||||||
|
> |
||||||
|
<span>Sign Up</span> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
|
||||||
|
<!-- Mobile Navigation Button --> |
||||||
|
<button |
||||||
|
class='block cursor-pointer text-gray-400 hover:text-gray-50 sm:hidden' |
||||||
|
aria-label='Menu' |
||||||
|
data-show-mobile-nav |
||||||
|
> |
||||||
|
<Icon icon='hamburger' /> |
||||||
|
</button> |
||||||
|
|
||||||
|
<!-- Mobile Navigation Items --> |
||||||
|
<div |
||||||
|
class='fixed bottom-0 left-0 right-0 top-0 z-40 flex hidden items-center bg-slate-900' |
||||||
|
data-mobile-nav |
||||||
|
> |
||||||
|
<button |
||||||
|
data-close-mobile-nav |
||||||
|
class='absolute right-6 top-6 block cursor-pointer text-gray-400 hover:text-gray-50' |
||||||
|
aria-label='Close Menu' |
||||||
|
> |
||||||
|
<Icon icon='close' /> |
||||||
|
</button> |
||||||
|
<ul class='flex w-full flex-col items-center gap-2 md:gap-3'> |
||||||
|
<li> |
||||||
|
<a href='/roadmaps' class='text-xl hover:text-blue-300 md:text-lg'> |
||||||
|
Roadmaps |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
href='/best-practices' |
||||||
|
class='text-xl hover:text-blue-300 md:text-lg' |
||||||
|
> |
||||||
|
Best Practices |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href='/guides' class='text-xl hover:text-blue-300 md:text-lg'> |
||||||
|
Guides |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a href='/videos' class='text-xl hover:text-blue-300 md:text-lg'> |
||||||
|
Videos |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
|
||||||
|
<!-- Links for logged in users --> |
||||||
|
<li data-auth-required class='hidden'> |
||||||
|
<a |
||||||
|
href='/settings/update-profile' |
||||||
|
class='text-xl hover:text-blue-300 md:text-lg' |
||||||
|
> |
||||||
|
Settings |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li data-auth-required class='hidden'> |
||||||
|
<button |
||||||
|
data-logout-button |
||||||
|
class='text-xl text-red-300 hover:text-red-400 md:text-lg' |
||||||
|
> |
||||||
|
Logout |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
data-guest-required |
||||||
|
href='/signup' |
||||||
|
class='hidden text-xl text-white md:text-lg' |
||||||
|
> |
||||||
|
Login |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
data-guest-required |
||||||
|
href='/signup' |
||||||
|
class='hidden text-xl text-green-300 hover:text-green-400 md:text-lg' |
||||||
|
> |
||||||
|
Sign Up |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script src='./navigation.ts'></script> |
@ -0,0 +1,39 @@ |
|||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { handleAuthRequired } from '../Authenticator/authenticator'; |
||||||
|
import {TOKEN_COOKIE_NAME} from "../../lib/jwt"; |
||||||
|
|
||||||
|
export function logout() { |
||||||
|
Cookies.remove(TOKEN_COOKIE_NAME); |
||||||
|
// Reloading will automatically redirect the user if required
|
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
|
||||||
|
function bindEvents() { |
||||||
|
document.addEventListener('click', (e) => { |
||||||
|
const target = e.target as HTMLElement; |
||||||
|
const dataset = { |
||||||
|
...target.dataset, |
||||||
|
...target.closest('button')?.dataset, |
||||||
|
}; |
||||||
|
|
||||||
|
// If the user clicks on the logout button, remove the token cookie
|
||||||
|
if (dataset.logoutButton !== undefined) { |
||||||
|
logout(); |
||||||
|
} else if (dataset.showMobileNav !== undefined) { |
||||||
|
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden'); |
||||||
|
} else if (dataset.closeMobileNav !== undefined) { |
||||||
|
document.querySelector('[data-mobile-nav]')?.classList.add('hidden'); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
document |
||||||
|
.querySelector('[data-account-button]') |
||||||
|
?.addEventListener('click', (e) => { |
||||||
|
e.stopPropagation(); |
||||||
|
document |
||||||
|
.querySelector('[data-account-dropdown]') |
||||||
|
?.classList.toggle('hidden'); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
bindEvents(); |
@ -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> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,81 @@ |
|||||||
|
--- |
||||||
|
import Icon from '../AstroIcon.astro'; |
||||||
|
const { pageUrl, name } = Astro.props; |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
pageUrl: string; |
||||||
|
name: string; |
||||||
|
} |
||||||
|
--- |
||||||
|
|
||||||
|
<div |
||||||
|
class='container flex min-h-[calc(100vh-37px-70px)] items-stretch sm:min-h-[calc(100vh-37px-96px)]' |
||||||
|
> |
||||||
|
<aside class='hidden w-56 border-r border-slate-200 py-10 pr-5 md:block'> |
||||||
|
<nav> |
||||||
|
<ul class='space-y-1'> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
href='/settings/update-profile' |
||||||
|
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}` |
||||||
|
>Profile</a |
||||||
|
> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
href='/settings/update-password' |
||||||
|
class=`block w-full rounded px-2 py-1.5 font-regular text-slate-900 hover:bg-slate-100 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}` |
||||||
|
>Security</a |
||||||
|
> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</nav> |
||||||
|
</aside> |
||||||
|
<div class='grow py-10 pl-0 md:p-10 md:pr-0'> |
||||||
|
<div class='relative mb-5 md:hidden'> |
||||||
|
<button |
||||||
|
class='flex h-10 w-full items-center justify-between rounded-md bg-slate-800 px-2 text-center font-medium text-slate-100' |
||||||
|
id='settings-menu' |
||||||
|
> |
||||||
|
{name} |
||||||
|
<Icon icon='dropdown' /> |
||||||
|
</button> |
||||||
|
<ul |
||||||
|
id='settings-menu-dropdown' |
||||||
|
class='absolute mt-1 hidden w-full space-y-1.5 rounded-md bg-white p-2 shadow-lg' |
||||||
|
> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
href='/settings/update-profile' |
||||||
|
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'profile' ? 'bg-slate-100' : ''}` |
||||||
|
>Profile</a |
||||||
|
> |
||||||
|
</li> |
||||||
|
<li> |
||||||
|
<a |
||||||
|
href='/settings/update-password' |
||||||
|
class=`block w-full rounded px-2 py-1.5 font-medium text-slate-900 hover:bg-slate-200 ${pageUrl === 'change-password' ? 'bg-slate-100' : ''}` |
||||||
|
>Change password</a |
||||||
|
> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<slot /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<script> |
||||||
|
const menuButton = document.getElementById('settings-menu'); |
||||||
|
const menuDropdown = document.getElementById('settings-menu-dropdown'); |
||||||
|
|
||||||
|
menuButton?.addEventListener('click', () => { |
||||||
|
menuDropdown?.classList.toggle('hidden'); |
||||||
|
}); |
||||||
|
|
||||||
|
document.addEventListener('click', (e) => { |
||||||
|
if (!menuButton?.contains(e.target as Node)) { |
||||||
|
menuDropdown?.classList.add('hidden'); |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
@ -0,0 +1,175 @@ |
|||||||
|
import { useCallback, useEffect, useState } from 'preact/hooks'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
import { httpGet, httpPost } from '../../lib/http'; |
||||||
|
import { pageLoadingMessage } from '../../stores/page'; |
||||||
|
|
||||||
|
export default function UpdatePasswordForm() { |
||||||
|
const [authProvider, setAuthProvider] = useState(''); |
||||||
|
const [currentPassword, setCurrentPassword] = useState(''); |
||||||
|
const [newPassword, setNewPassword] = useState(''); |
||||||
|
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState(''); |
||||||
|
|
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [success, setSuccess] = useState(''); |
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true); |
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => { |
||||||
|
e.preventDefault(); |
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
setSuccess(''); |
||||||
|
|
||||||
|
if (newPassword !== newPasswordConfirmation) { |
||||||
|
setError('Passwords do not match'); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { response, error } = await httpPost( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-update-password`, |
||||||
|
{ |
||||||
|
oldPassword: authProvider === 'email' ? currentPassword : 'social-auth', |
||||||
|
password: newPassword, |
||||||
|
confirmPassword: newPasswordConfirmation, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error) { |
||||||
|
setError(error.message || 'Something went wrong'); |
||||||
|
setIsLoading(false); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setError(''); |
||||||
|
setCurrentPassword(''); |
||||||
|
setNewPassword(''); |
||||||
|
setNewPasswordConfirmation(''); |
||||||
|
setSuccess('Password updated successfully'); |
||||||
|
setIsLoading(false); |
||||||
|
}; |
||||||
|
|
||||||
|
const loadProfile = async () => { |
||||||
|
setIsLoading(true); |
||||||
|
|
||||||
|
const { error, response } = await httpGet( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-me` |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
setIsLoading(false); |
||||||
|
setError(error?.message || 'Something went wrong'); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { authProvider } = response; |
||||||
|
setAuthProvider(authProvider); |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
}; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
pageLoadingMessage.set('Loading profile'); |
||||||
|
loadProfile().finally(() => { |
||||||
|
pageLoadingMessage.set(''); |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<h2 className="text-3xl font-bold sm:text-4xl">Password</h2> |
||||||
|
<p className="mt-2">Use the form below to update your password.</p> |
||||||
|
<div className="mt-8 space-y-4"> |
||||||
|
{authProvider === 'email' && ( |
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label |
||||||
|
for="current-password" |
||||||
|
className="text-sm leading-none text-slate-500" |
||||||
|
> |
||||||
|
Current Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
disabled={authProvider !== 'email'} |
||||||
|
type="password" |
||||||
|
name="current-password" |
||||||
|
id="current-password" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-100" |
||||||
|
required |
||||||
|
minLength={6} |
||||||
|
placeholder="Current password" |
||||||
|
value={currentPassword} |
||||||
|
onInput={(e) => |
||||||
|
setCurrentPassword((e.target as HTMLInputElement).value) |
||||||
|
} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label |
||||||
|
for="new-password" |
||||||
|
className="text-sm leading-none text-slate-500" |
||||||
|
> |
||||||
|
New Password |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
name="new-password" |
||||||
|
id="new-password" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
minLength={6} |
||||||
|
placeholder="New password" |
||||||
|
value={newPassword} |
||||||
|
onInput={(e) => |
||||||
|
setNewPassword((e.target as HTMLInputElement).value) |
||||||
|
} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label |
||||||
|
for="new-password-confirmation" |
||||||
|
className="text-sm leading-none text-slate-500" |
||||||
|
> |
||||||
|
New Password Confirm |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
name="new-password-confirmation" |
||||||
|
id="new-password-confirmation" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
minLength={6} |
||||||
|
placeholder="New password confirm" |
||||||
|
value={newPasswordConfirmation} |
||||||
|
onInput={(e) => |
||||||
|
setNewPasswordConfirmation((e.target as HTMLInputElement).value) |
||||||
|
} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p class="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p> |
||||||
|
)} |
||||||
|
|
||||||
|
{success && ( |
||||||
|
<p class="mt-2 rounded-lg bg-green-100 p-2 text-green-700"> |
||||||
|
{success} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isLoading} |
||||||
|
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Update Password'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,203 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
import { httpGet, httpPost } from '../../lib/http'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from '../../lib/jwt'; |
||||||
|
import { pageLoadingMessage } from '../../stores/page'; |
||||||
|
|
||||||
|
export function UpdateProfileForm() { |
||||||
|
const [name, setName] = useState(''); |
||||||
|
const [email, setEmail] = useState(''); |
||||||
|
const [github, setGithub] = useState(''); |
||||||
|
const [twitter, setTwitter] = useState(''); |
||||||
|
const [linkedin, setLinkedin] = useState(''); |
||||||
|
const [website, setWebsite] = useState(''); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
|
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [success, setSuccess] = useState(''); |
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => { |
||||||
|
e.preventDefault(); |
||||||
|
setIsLoading(true); |
||||||
|
setError(''); |
||||||
|
setSuccess(''); |
||||||
|
|
||||||
|
const { response, error } = await httpPost( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-update-profile`, |
||||||
|
{ |
||||||
|
name, |
||||||
|
github: github || undefined, |
||||||
|
linkedin: linkedin || undefined, |
||||||
|
twitter: twitter || undefined, |
||||||
|
website: website || undefined, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
setIsLoading(false); |
||||||
|
setError(error?.message || 'Something went wrong'); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await loadProfile(); |
||||||
|
setSuccess('Profile updated successfully'); |
||||||
|
}; |
||||||
|
|
||||||
|
const loadProfile = async () => { |
||||||
|
// Set the loading state
|
||||||
|
setIsLoading(true); |
||||||
|
|
||||||
|
const { error, response } = await httpGet( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-me` |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response) { |
||||||
|
setIsLoading(false); |
||||||
|
setError(error?.message || 'Something went wrong'); |
||||||
|
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { name, email, links } = response; |
||||||
|
|
||||||
|
setName(name); |
||||||
|
setEmail(email); |
||||||
|
setGithub(links?.github || ''); |
||||||
|
setLinkedin(links?.linkedin || ''); |
||||||
|
setTwitter(links?.twitter || ''); |
||||||
|
setWebsite(links?.website || ''); |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
}; |
||||||
|
|
||||||
|
// Make a request to the backend to fill in the form with the current values
|
||||||
|
useEffect(() => { |
||||||
|
pageLoadingMessage.set('Loading profile'); |
||||||
|
loadProfile().finally(() => { |
||||||
|
pageLoadingMessage.set(''); |
||||||
|
}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
return ( |
||||||
|
<form onSubmit={handleSubmit}> |
||||||
|
<h2 className="text-3xl font-bold sm:text-4xl">Profile</h2> |
||||||
|
<p className="mt-2">Update your profile details below.</p> |
||||||
|
<div className="mt-8 space-y-4"> |
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label |
||||||
|
for="name" |
||||||
|
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]' |
||||||
|
> |
||||||
|
Name |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
name="name" |
||||||
|
id="name" |
||||||
|
className="mt-2 block w-full appearance-none rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
placeholder="John Doe" |
||||||
|
value={name} |
||||||
|
onInput={(e) => setName((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label |
||||||
|
for="email" |
||||||
|
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]' |
||||||
|
> |
||||||
|
Email |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="email" |
||||||
|
name="email" |
||||||
|
id="email" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
required |
||||||
|
disabled |
||||||
|
placeholder="john@example.com" |
||||||
|
value={email} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label for="github" className="text-sm leading-none text-slate-500"> |
||||||
|
Github |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
name="github" |
||||||
|
id="github" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="https://github.com/username" |
||||||
|
value={github} |
||||||
|
onInput={(e) => setGithub((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label for="twitter" className="text-sm leading-none text-slate-500"> |
||||||
|
Twitter |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
name="twitter" |
||||||
|
id="twitter" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="https://twitter.com/username" |
||||||
|
value={twitter} |
||||||
|
onInput={(e) => setTwitter((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label for="linkedin" className="text-sm leading-none text-slate-500"> |
||||||
|
LinkedIn |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
name="linkedin" |
||||||
|
id="linkedin" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="https://www.linkedin.com/in/username/" |
||||||
|
value={linkedin} |
||||||
|
onInput={(e) => setLinkedin((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex w-full flex-col"> |
||||||
|
<label for="website" className="text-sm leading-none text-slate-500"> |
||||||
|
Website |
||||||
|
</label> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
name="website" |
||||||
|
id="website" |
||||||
|
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1" |
||||||
|
placeholder="https://example.com" |
||||||
|
value={website} |
||||||
|
onInput={(e) => setWebsite((e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{error && ( |
||||||
|
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p> |
||||||
|
)} |
||||||
|
|
||||||
|
{success && ( |
||||||
|
<p className="mt-2 rounded-lg bg-green-100 p-2 text-green-700"> |
||||||
|
{success} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={isLoading} |
||||||
|
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400" |
||||||
|
> |
||||||
|
{isLoading ? 'Please wait...' : 'Continue'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
); |
||||||
|
} |
@ -1,41 +0,0 @@ |
|||||||
--- |
|
||||||
import Popup from './Popup/Popup.astro'; |
|
||||||
import CaptchaFields from './Captcha/CaptchaFields.astro'; |
|
||||||
--- |
|
||||||
|
|
||||||
<Popup id='subscribe-popup' title='Subscribe' subtitle='Enter your email below to receive updates.'> |
|
||||||
<form |
|
||||||
action='https://news.roadmap.sh/subscribe' |
|
||||||
method='POST' |
|
||||||
accept-charset='utf-8' |
|
||||||
target='_blank' |
|
||||||
captcha-form |
|
||||||
> |
|
||||||
<input type='hidden' name='gdpr' value='true' /> |
|
||||||
|
|
||||||
<input |
|
||||||
type='email' |
|
||||||
name='email' |
|
||||||
required |
|
||||||
autofocus |
|
||||||
class='w-full rounded-md border text-md py-2.5 px-3 mb-2' |
|
||||||
placeholder='Enter your Email' |
|
||||||
/> |
|
||||||
|
|
||||||
<CaptchaFields /> |
|
||||||
|
|
||||||
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' /> |
|
||||||
<input type='hidden' name='subform' value='yes' /> |
|
||||||
|
|
||||||
<button |
|
||||||
type='submit' |
|
||||||
name='submit' |
|
||||||
class='text-white bg-gradient-to-r from-amber-700 to-blue-800 hover:from-amber-800 hover:to-blue-900 font-regular rounded-md text-md px-5 py-2.5 w-full text-center mr-2' |
|
||||||
ga-category='Subscription' |
|
||||||
ga-action='Submitted Popup Form' |
|
||||||
ga-label='Subscribe Roadmap Popup' |
|
||||||
> |
|
||||||
Subscribe |
|
||||||
</button> |
|
||||||
</form> |
|
||||||
</Popup> |
|
@ -0,0 +1,261 @@ |
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; |
||||||
|
import SpinnerIcon from '../../icons/spinner.svg'; |
||||||
|
import CheckIcon from '../../icons/check.svg'; |
||||||
|
import ResetIcon from '../../icons/reset.svg'; |
||||||
|
import CloseIcon from '../../icons/close.svg'; |
||||||
|
|
||||||
|
import { useOutsideClick } from '../../hooks/use-outside-click'; |
||||||
|
import { useLoadTopic } from '../../hooks/use-load-topic'; |
||||||
|
import { httpGet } from '../../lib/http'; |
||||||
|
import { isLoggedIn } from '../../lib/jwt'; |
||||||
|
import { |
||||||
|
isTopicDone, |
||||||
|
renderTopicProgress, |
||||||
|
ResourceType, |
||||||
|
toggleMarkTopicDone as toggleMarkTopicDoneApi, |
||||||
|
} from '../../lib/resource-progress'; |
||||||
|
import { useKeydown } from '../../hooks/use-keydown'; |
||||||
|
import { useToggleTopic } from '../../hooks/use-toggle-topic'; |
||||||
|
import { pageLoadingMessage } from '../../stores/page'; |
||||||
|
|
||||||
|
export function TopicDetail() { |
||||||
|
const [isActive, setIsActive] = useState(false); |
||||||
|
const [isLoading, setIsLoading] = useState(false); |
||||||
|
const [error, setError] = useState(''); |
||||||
|
const [topicHtml, setTopicHtml] = useState(''); |
||||||
|
|
||||||
|
const [isDone, setIsDone] = useState<boolean>(); |
||||||
|
const [isUpdatingProgress, setIsUpdatingProgress] = useState(true); |
||||||
|
|
||||||
|
const isGuest = useMemo(() => !isLoggedIn(), []); |
||||||
|
const topicRef = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
// Details of the currently loaded topic
|
||||||
|
const [topicId, setTopicId] = useState(''); |
||||||
|
const [resourceId, setResourceId] = useState(''); |
||||||
|
const [resourceType, setResourceType] = useState<ResourceType>('roadmap'); |
||||||
|
|
||||||
|
const showLoginPopup = () => { |
||||||
|
const popupEl = document.querySelector(`#login-popup`); |
||||||
|
if (!popupEl) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
popupEl.classList.remove('hidden'); |
||||||
|
popupEl.classList.add('flex'); |
||||||
|
const focusEl = popupEl.querySelector<HTMLElement>('[autofocus]'); |
||||||
|
if (focusEl) { |
||||||
|
focusEl.focus(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const toggleMarkTopicDone = (isDone: boolean) => { |
||||||
|
setIsUpdatingProgress(true); |
||||||
|
toggleMarkTopicDoneApi({ topicId, resourceId, resourceType }, isDone) |
||||||
|
.then(() => { |
||||||
|
setIsDone(isDone); |
||||||
|
setIsActive(false); |
||||||
|
renderTopicProgress(topicId, isDone); |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
alert(err.message); |
||||||
|
console.error(err); |
||||||
|
}) |
||||||
|
.finally(() => { |
||||||
|
setIsUpdatingProgress(false); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
// Load the topic status when the topic detail is active
|
||||||
|
useEffect(() => { |
||||||
|
if (!topicId || !resourceId || !resourceType) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
setIsUpdatingProgress(true); |
||||||
|
isTopicDone({ topicId, resourceId, resourceType }) |
||||||
|
.then((status: boolean) => { |
||||||
|
setIsUpdatingProgress(false); |
||||||
|
setIsDone(status); |
||||||
|
}) |
||||||
|
.catch(console.error); |
||||||
|
}, [topicId, resourceId, resourceType]); |
||||||
|
|
||||||
|
// Close the topic detail when user clicks outside the topic detail
|
||||||
|
useOutsideClick(topicRef, () => { |
||||||
|
setIsActive(false); |
||||||
|
}); |
||||||
|
|
||||||
|
useKeydown('Escape', () => { |
||||||
|
setIsActive(false); |
||||||
|
}); |
||||||
|
|
||||||
|
// Toggle topic is available even if the component UI is not active
|
||||||
|
// This is used on the best practice screen where we have the checkboxes
|
||||||
|
// to mark the topic as done/undone.
|
||||||
|
useToggleTopic(({ topicId, resourceType, resourceId }) => { |
||||||
|
if (isGuest) { |
||||||
|
showLoginPopup(); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
pageLoadingMessage.set('Updating'); |
||||||
|
|
||||||
|
// Toggle the topic status
|
||||||
|
isTopicDone({ topicId, resourceId, resourceType }) |
||||||
|
.then((oldIsDone) => { |
||||||
|
return toggleMarkTopicDoneApi( |
||||||
|
{ |
||||||
|
topicId, |
||||||
|
resourceId, |
||||||
|
resourceType, |
||||||
|
}, |
||||||
|
!oldIsDone |
||||||
|
); |
||||||
|
}) |
||||||
|
.then((newIsDone) => renderTopicProgress(topicId, newIsDone)) |
||||||
|
.catch((err) => { |
||||||
|
alert(err.message); |
||||||
|
console.error(err); |
||||||
|
}) |
||||||
|
.finally(() => { |
||||||
|
pageLoadingMessage.set(''); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Load the topic detail when the topic detail is active
|
||||||
|
useLoadTopic(({ topicId, resourceType, resourceId }) => { |
||||||
|
setIsLoading(true); |
||||||
|
setIsActive(true); |
||||||
|
|
||||||
|
setTopicId(topicId); |
||||||
|
setResourceType(resourceType); |
||||||
|
setResourceId(resourceId); |
||||||
|
|
||||||
|
const topicPartial = topicId.replaceAll(':', '/'); |
||||||
|
const topicUrl = |
||||||
|
resourceType === 'roadmap' |
||||||
|
? `/${resourceId}/${topicPartial}` |
||||||
|
: `/best-practices/${resourceId}/${topicPartial}`; |
||||||
|
|
||||||
|
httpGet<string>( |
||||||
|
topicUrl, |
||||||
|
{}, |
||||||
|
{ |
||||||
|
headers: { |
||||||
|
Accept: 'text/html', |
||||||
|
}, |
||||||
|
} |
||||||
|
) |
||||||
|
.then(({ response }) => { |
||||||
|
if (!response) { |
||||||
|
setError('Topic not found.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// It's full HTML with page body, head etc.
|
||||||
|
// We only need the inner HTML of the #main-content
|
||||||
|
const node = new DOMParser().parseFromString(response, 'text/html'); |
||||||
|
const topicHtml = node?.getElementById('main-content')?.outerHTML || ''; |
||||||
|
|
||||||
|
setIsLoading(false); |
||||||
|
setTopicHtml(topicHtml); |
||||||
|
}) |
||||||
|
.catch((err) => { |
||||||
|
setError('Something went wrong. Please try again later.'); |
||||||
|
setIsLoading(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
if (!isActive) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<div |
||||||
|
ref={topicRef} |
||||||
|
className="fixed right-0 top-0 z-40 h-screen w-full overflow-y-auto bg-white p-4 sm:max-w-[600px] sm:p-6" |
||||||
|
> |
||||||
|
{isLoading && ( |
||||||
|
<div className="flex w-full justify-center"> |
||||||
|
<img |
||||||
|
src={SpinnerIcon} |
||||||
|
alt="Loading" |
||||||
|
className="h-6 w-6 animate-spin fill-blue-600 text-gray-200 sm:h-12 sm:w-12" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isLoading && !error && ( |
||||||
|
<> |
||||||
|
{/* Actions for the topic */} |
||||||
|
<div className="mb-2"> |
||||||
|
{isGuest && ( |
||||||
|
<button |
||||||
|
data-popup="login-popup" |
||||||
|
className="inline-flex items-center rounded-md bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700" |
||||||
|
onClick={() => setIsActive(false)} |
||||||
|
> |
||||||
|
<img alt="Check" src={CheckIcon} /> |
||||||
|
<span className="ml-2">Mark as Done</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isGuest && ( |
||||||
|
<> |
||||||
|
{isUpdatingProgress && ( |
||||||
|
<button className="inline-flex cursor-default items-center rounded-md border border-gray-300 bg-white p-1 px-2 text-sm text-black"> |
||||||
|
<img |
||||||
|
alt="Check" |
||||||
|
class="h-4 w-4 animate-spin" |
||||||
|
src={SpinnerIcon} |
||||||
|
/> |
||||||
|
<span className="ml-2">Updating Status..</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
{!isUpdatingProgress && !isDone && ( |
||||||
|
<button |
||||||
|
className="inline-flex items-center rounded-md border border-green-600 bg-green-600 p-1 px-2 text-sm text-white hover:bg-green-700" |
||||||
|
onClick={() => toggleMarkTopicDone(true)} |
||||||
|
> |
||||||
|
<img alt="Check" class="h-4 w-4" src={CheckIcon} /> |
||||||
|
<span className="ml-2">Mark as Done</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
|
||||||
|
{!isUpdatingProgress && isDone && ( |
||||||
|
<button |
||||||
|
className="inline-flex items-center rounded-md border border-red-600 bg-red-600 p-1 px-2 text-sm text-white hover:bg-red-700" |
||||||
|
onClick={() => toggleMarkTopicDone(false)} |
||||||
|
> |
||||||
|
<img alt="Check" class="h-4" src={ResetIcon} /> |
||||||
|
<span className="ml-2">Mark as Pending</span> |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</> |
||||||
|
)} |
||||||
|
|
||||||
|
<button |
||||||
|
type="button" |
||||||
|
id="close-topic" |
||||||
|
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900" |
||||||
|
onClick={() => setIsActive(false)} |
||||||
|
> |
||||||
|
<img alt="Close" class="h-5 w-5" src={CloseIcon} /> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Topic Content */} |
||||||
|
<div |
||||||
|
id="topic-content" |
||||||
|
className="prose prose-quoteless prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-li:m-0 prose-li:mb-0.5" |
||||||
|
dangerouslySetInnerHTML={{ __html: topicHtml }} |
||||||
|
></div> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div class="fixed inset-0 z-30 bg-gray-900 bg-opacity-50 dark:bg-opacity-80"></div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,16 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
export function useKeydown(keyName: string, callback: any) { |
||||||
|
useEffect(() => { |
||||||
|
const listener = (event: any) => { |
||||||
|
if (event.key.toLowerCase() === keyName.toLowerCase()) { |
||||||
|
callback(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
window.addEventListener('keydown', listener); |
||||||
|
return () => { |
||||||
|
window.removeEventListener('keydown', listener); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
import { useEffect } from 'preact/hooks'; |
||||||
|
import type { ResourceType } from '../lib/resource-progress'; |
||||||
|
|
||||||
|
type CallbackType = (data: { |
||||||
|
resourceType: ResourceType; |
||||||
|
resourceId: string; |
||||||
|
topicId: string; |
||||||
|
}) => void; |
||||||
|
|
||||||
|
export function useLoadTopic(callback: CallbackType) { |
||||||
|
useEffect(() => { |
||||||
|
function handleTopicClick(e: any) { |
||||||
|
const { resourceType, resourceId, topicId } = e.detail; |
||||||
|
|
||||||
|
callback({ |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
topicId, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
window.addEventListener(`roadmap.topic.click`, handleTopicClick); |
||||||
|
window.addEventListener(`best-practice.topic.click`, handleTopicClick); |
||||||
|
|
||||||
|
return () => { |
||||||
|
window.removeEventListener(`roadmap.topic.click`, handleTopicClick); |
||||||
|
window.removeEventListener(`best-practice.topic.click`, handleTopicClick); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
import { useEffect, useState } from 'preact/hooks'; |
||||||
|
|
||||||
|
export function useOutsideClick(ref: any, callback: any) { |
||||||
|
useEffect(() => { |
||||||
|
const listener = (event: any) => { |
||||||
|
const isClickedOutside = !ref?.current?.contains(event.target); |
||||||
|
if (isClickedOutside) { |
||||||
|
callback(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
document.addEventListener('mousedown', listener); |
||||||
|
document.addEventListener('touchstart', listener); |
||||||
|
|
||||||
|
return () => { |
||||||
|
document.removeEventListener('mousedown', listener); |
||||||
|
document.removeEventListener('touchstart', listener); |
||||||
|
}; |
||||||
|
}, [ref]); |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
import { useEffect } from 'preact/hooks'; |
||||||
|
import type { ResourceType } from '../lib/resource-progress'; |
||||||
|
|
||||||
|
type CallbackType = (data: { |
||||||
|
resourceType: ResourceType; |
||||||
|
resourceId: string; |
||||||
|
topicId: string; |
||||||
|
}) => void; |
||||||
|
|
||||||
|
export function useToggleTopic(callback: CallbackType) { |
||||||
|
useEffect(() => { |
||||||
|
function handleToggleTopic(e: any) { |
||||||
|
const { resourceType, resourceId, topicId } = e.detail; |
||||||
|
|
||||||
|
callback({ |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
topicId, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
window.addEventListener(`best-practice.topic.toggle`, handleToggleTopic); |
||||||
|
return () => { |
||||||
|
window.removeEventListener( |
||||||
|
`best-practice.topic.toggle`, |
||||||
|
handleToggleTopic |
||||||
|
); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
} |
Before Width: | Height: | Size: 230 B After Width: | Height: | Size: 208 B |
After Width: | Height: | Size: 227 B |
After Width: | Height: | Size: 227 B |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 941 B |
After Width: | Height: | Size: 688 B |
Before Width: | Height: | Size: 932 B After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1,12 @@ |
|||||||
|
--- |
||||||
|
import BaseLayout,{ Props as BaseLayoutProps } from './BaseLayout.astro'; |
||||||
|
|
||||||
|
export interface Props extends BaseLayoutProps {} |
||||||
|
|
||||||
|
const props = Astro.props; |
||||||
|
--- |
||||||
|
|
||||||
|
<BaseLayout {...props}> |
||||||
|
<slot /> |
||||||
|
<div slot='page-footer'></div> |
||||||
|
</BaseLayout> |
@ -0,0 +1,153 @@ |
|||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import fp from '@fingerprintjs/fingerprintjs'; |
||||||
|
import { TOKEN_COOKIE_NAME } from './jwt'; |
||||||
|
|
||||||
|
type HttpOptionsType = RequestInit | { headers: Record<string, any> }; |
||||||
|
|
||||||
|
type AppResponse = Record<string, any>; |
||||||
|
type FetchError = { |
||||||
|
status: number; |
||||||
|
message: string; |
||||||
|
}; |
||||||
|
type AppError = { |
||||||
|
status: number; |
||||||
|
message: string; |
||||||
|
errors?: { message: string; location: string }[]; |
||||||
|
}; |
||||||
|
|
||||||
|
type ApiReturn<ResponseType, ErrorType> = { |
||||||
|
response?: ResponseType; |
||||||
|
error?: ErrorType | FetchError; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Wrapper around fetch to make it easy to handle errors |
||||||
|
* |
||||||
|
* @param url |
||||||
|
* @param options |
||||||
|
*/ |
||||||
|
export async function httpCall< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
options?: HttpOptionsType |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
try { |
||||||
|
const fingerprintPromise = await fp.load({ monitoring: false }); |
||||||
|
const fingerprint = await fingerprintPromise.get(); |
||||||
|
|
||||||
|
const response = await fetch(url, { |
||||||
|
credentials: 'include', |
||||||
|
...options, |
||||||
|
headers: new Headers({ |
||||||
|
'Content-Type': 'application/json', |
||||||
|
Accept: 'application/json', |
||||||
|
Authorization: `Bearer ${Cookies.get(TOKEN_COOKIE_NAME)}`, |
||||||
|
'fp': fingerprint.visitorId, |
||||||
|
...(options?.headers ?? {}), |
||||||
|
}), |
||||||
|
}); |
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const doesAcceptHtml = options?.headers?.['Accept'] === 'text/html'; |
||||||
|
|
||||||
|
const data = doesAcceptHtml ? await response.text() : await response.json(); |
||||||
|
|
||||||
|
if (response.ok) { |
||||||
|
return { |
||||||
|
response: data as ResponseType, |
||||||
|
error: undefined, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Logout user if token is invalid
|
||||||
|
if (data.status === 401) { |
||||||
|
Cookies.remove(TOKEN_COOKIE_NAME); |
||||||
|
window.location.reload(); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
response: undefined, |
||||||
|
error: data as ErrorType, |
||||||
|
}; |
||||||
|
} catch (error: any) { |
||||||
|
return { |
||||||
|
response: undefined, |
||||||
|
error: { |
||||||
|
status: 0, |
||||||
|
message: error.message, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export async function httpPost< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
body: Record<string, any>, |
||||||
|
options?: HttpOptionsType |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return httpCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export async function httpGet<ResponseType = AppResponse, ErrorType = AppError>( |
||||||
|
url: string, |
||||||
|
queryParams?: Record<string, any>, |
||||||
|
options?: HttpOptionsType |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
const searchParams = new URLSearchParams(queryParams).toString(); |
||||||
|
const queryUrl = searchParams ? `${url}?${searchParams}` : url; |
||||||
|
|
||||||
|
return httpCall<ResponseType, ErrorType>(queryUrl, { |
||||||
|
credentials: 'include', |
||||||
|
method: 'GET', |
||||||
|
...options, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export async function httpPatch< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
body: Record<string, any>, |
||||||
|
options?: HttpOptionsType |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return httpCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'PATCH', |
||||||
|
body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export async function httpPut<ResponseType = AppResponse, ErrorType = AppError>( |
||||||
|
url: string, |
||||||
|
body: Record<string, any>, |
||||||
|
options?: HttpOptionsType |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return httpCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'PUT', |
||||||
|
body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export async function httpDelete< |
||||||
|
ResponseType = AppResponse, |
||||||
|
ErrorType = AppError |
||||||
|
>( |
||||||
|
url: string, |
||||||
|
options?: HttpOptionsType |
||||||
|
): Promise<ApiReturn<ResponseType, ErrorType>> { |
||||||
|
return httpCall<ResponseType, ErrorType>(url, { |
||||||
|
...options, |
||||||
|
method: 'DELETE', |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
import * as jose from 'jose'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
|
||||||
|
export const TOKEN_COOKIE_NAME = '__roadmapsh_jt__'; |
||||||
|
|
||||||
|
export type TokenPayload = { |
||||||
|
id: string; |
||||||
|
email: string; |
||||||
|
name: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export function decodeToken(token: string): TokenPayload { |
||||||
|
const claims = jose.decodeJwt(token); |
||||||
|
|
||||||
|
return claims as TokenPayload; |
||||||
|
} |
||||||
|
|
||||||
|
export function isLoggedIn() { |
||||||
|
const token = Cookies.get(TOKEN_COOKIE_NAME); |
||||||
|
|
||||||
|
return !!token; |
||||||
|
} |
@ -0,0 +1,163 @@ |
|||||||
|
import { httpGet, httpPatch } from './http'; |
||||||
|
import Cookies from 'js-cookie'; |
||||||
|
import { TOKEN_COOKIE_NAME } from './jwt'; |
||||||
|
import Element = astroHTML.JSX.Element; |
||||||
|
|
||||||
|
export type ResourceType = 'roadmap' | 'best-practice'; |
||||||
|
|
||||||
|
type TopicMeta = { |
||||||
|
topicId: string; |
||||||
|
resourceType: ResourceType; |
||||||
|
resourceId: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export async function isTopicDone(topic: TopicMeta): Promise<boolean> { |
||||||
|
const { topicId, resourceType, resourceId } = topic; |
||||||
|
const doneItems = await getResourceProgress(resourceType, resourceId); |
||||||
|
|
||||||
|
if (!doneItems) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return doneItems.includes(topicId); |
||||||
|
} |
||||||
|
|
||||||
|
export async function toggleMarkTopicDone( |
||||||
|
topic: TopicMeta, |
||||||
|
isDone: boolean |
||||||
|
): Promise<boolean> { |
||||||
|
const { topicId, resourceType, resourceId } = topic; |
||||||
|
|
||||||
|
const { response, error } = await httpPatch<{ done: string[] }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-toggle-mark-resource-done`, |
||||||
|
{ |
||||||
|
topicId, |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
isDone, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error || !response?.done) { |
||||||
|
throw new Error(error?.message || 'Something went wrong'); |
||||||
|
} |
||||||
|
|
||||||
|
setResourceProgress(resourceType, resourceId, response.done); |
||||||
|
|
||||||
|
return isDone; |
||||||
|
} |
||||||
|
|
||||||
|
export async function getResourceProgress( |
||||||
|
resourceType: 'roadmap' | 'best-practice', |
||||||
|
resourceId: string |
||||||
|
): Promise<string[]> { |
||||||
|
// No need to load progress if user is not logged in
|
||||||
|
if (!Cookies.get(TOKEN_COOKIE_NAME)) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
const progressKey = `${resourceType}-${resourceId}-progress`; |
||||||
|
|
||||||
|
const rawProgress = localStorage.getItem(progressKey); |
||||||
|
const progress = JSON.parse(rawProgress || 'null'); |
||||||
|
|
||||||
|
const progressTimestamp = progress?.timestamp; |
||||||
|
const diff = new Date().getTime() - parseInt(progressTimestamp || '0', 10); |
||||||
|
const isProgressExpired = diff > 15 * 60 * 1000; // 15 minutes
|
||||||
|
|
||||||
|
if (!progress || isProgressExpired) { |
||||||
|
return loadFreshProgress(resourceType, resourceId); |
||||||
|
} |
||||||
|
|
||||||
|
return progress.done; |
||||||
|
} |
||||||
|
|
||||||
|
async function loadFreshProgress( |
||||||
|
resourceType: ResourceType, |
||||||
|
resourceId: string |
||||||
|
) { |
||||||
|
const { response, error } = await httpGet<{ done: string[] }>( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-get-user-resource-progress`, |
||||||
|
{ |
||||||
|
resourceType, |
||||||
|
resourceId, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
if (error) { |
||||||
|
console.error(error); |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
if (!response?.done) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
setResourceProgress(resourceType, resourceId, response.done); |
||||||
|
|
||||||
|
return response.done; |
||||||
|
} |
||||||
|
|
||||||
|
export function setResourceProgress( |
||||||
|
resourceType: 'roadmap' | 'best-practice', |
||||||
|
resourceId: string, |
||||||
|
done: string[] |
||||||
|
): void { |
||||||
|
localStorage.setItem( |
||||||
|
`${resourceType}-${resourceId}-progress`, |
||||||
|
JSON.stringify({ |
||||||
|
done, |
||||||
|
timestamp: new Date().getTime(), |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function renderTopicProgress(topicId: string, isDone: boolean) { |
||||||
|
const matchingElements: Element[] = []; |
||||||
|
|
||||||
|
// Elements having sort order in the beginning of the group id
|
||||||
|
document |
||||||
|
.querySelectorAll(`[data-group-id$="-${topicId}"]`) |
||||||
|
.forEach((element: unknown) => { |
||||||
|
const foundGroupId = |
||||||
|
(element as HTMLOrSVGElement)?.dataset?.groupId || ''; |
||||||
|
const validGroupRegex = new RegExp(`^\\d+-${topicId}$`); |
||||||
|
|
||||||
|
if (validGroupRegex.test(foundGroupId)) { |
||||||
|
matchingElements.push(element); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Elements with exact match of the topic id
|
||||||
|
document |
||||||
|
.querySelectorAll(`[data-group-id="${topicId}"]`) |
||||||
|
.forEach((element) => { |
||||||
|
matchingElements.push(element); |
||||||
|
}); |
||||||
|
|
||||||
|
// Matching "check:XXXX" box of the topic
|
||||||
|
document |
||||||
|
.querySelectorAll(`[data-group-id="check:${topicId}"]`) |
||||||
|
.forEach((element) => { |
||||||
|
matchingElements.push(element); |
||||||
|
}); |
||||||
|
|
||||||
|
matchingElements.forEach((element) => { |
||||||
|
if (isDone) { |
||||||
|
element.classList.add('done'); |
||||||
|
} else { |
||||||
|
element.classList.remove('done'); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export async function renderResourceProgress( |
||||||
|
resourceType: ResourceType, |
||||||
|
resourceId: string |
||||||
|
) { |
||||||
|
const progress = await getResourceProgress(resourceType, resourceId); |
||||||
|
|
||||||
|
progress.forEach((topicId) => { |
||||||
|
renderTopicProgress(topicId, true); |
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
--- |
||||||
|
import { ForgotPasswordForm } from '../components/AuthenticationFlow/ForgotPasswordForm'; |
||||||
|
import SettingLayout from '../layouts/SettingLayout.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout title='Forgot Password'> |
||||||
|
<div class='container'> |
||||||
|
<div |
||||||
|
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20' |
||||||
|
> |
||||||
|
<div class='mb-2 text-left sm:mb-5 sm:text-center'> |
||||||
|
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'> |
||||||
|
Forgot Password? |
||||||
|
</h1> |
||||||
|
<p class='mb-3 text-base leading-6 text-gray-600'> |
||||||
|
Enter your email address below and we will send you a link to reset |
||||||
|
your password. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<ForgotPasswordForm client:load /> |
||||||
|
|
||||||
|
<div class='mt-6 text-center text-sm'> |
||||||
|
Don't have an account? <a |
||||||
|
href='/signup' |
||||||
|
class='font-medium text-blue-600 transition duration-150 ease-in-out hover:text-blue-500' |
||||||
|
>Sign up</a |
||||||
|
> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</SettingLayout> |
@ -0,0 +1,41 @@ |
|||||||
|
--- |
||||||
|
import Divider from '../components/AuthenticationFlow/Divider.astro'; |
||||||
|
import { GitHubButton } from '../components/AuthenticationFlow/GitHubButton'; |
||||||
|
import { GoogleButton } from '../components/AuthenticationFlow/GoogleButton'; |
||||||
|
import EmailLoginForm from '../components/AuthenticationFlow/EmailLoginForm'; |
||||||
|
import SettingLayout from '../layouts/SettingLayout.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout |
||||||
|
title='Login - roadmap.sh' |
||||||
|
description='Register yourself to receive occasional emails about new roadmaps, updates, guides and videos' |
||||||
|
permalink={'/signup'} |
||||||
|
noIndex={true} |
||||||
|
> |
||||||
|
<div class='container'> |
||||||
|
<div class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20'> |
||||||
|
<div class='mb-2 text-left sm:mb-5 sm:text-center'> |
||||||
|
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>Login</h1> |
||||||
|
<p class='text-base text-gray-600 leading-6 mb-3'> |
||||||
|
Welcome back! Let's take you to your account. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class='flex w-full flex-col gap-2'> |
||||||
|
<GitHubButton client:load /> |
||||||
|
<GoogleButton client:load /> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Divider /> |
||||||
|
|
||||||
|
<EmailLoginForm client:load /> |
||||||
|
|
||||||
|
<div class='mt-6 text-center text-sm text-slate-600'> |
||||||
|
Don't have an account?{' '} |
||||||
|
<a href='/signup' class='font-medium text-blue-700 hover:text-blue-600'> |
||||||
|
Sign up |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</SettingLayout> |
@ -1,43 +0,0 @@ |
|||||||
--- |
|
||||||
layout: ../layouts/MarkdownLayout.astro |
|
||||||
title: Roadmap PDFs - roadmap.sh |
|
||||||
noIndex: true |
|
||||||
--- |
|
||||||
|
|
||||||
# Download Roadmap PDFs |
|
||||||
|
|
||||||
Here is the list of PDF links for each of the roadmaps. |
|
||||||
|
|
||||||
- **Frontend Roadmap** - [Roadmap Link](https://roadmap.sh/frontend) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/frontend.pdf) |
|
||||||
- **Backend Roadmap** - [Roadmap Link](https://roadmap.sh/backend) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/backend.pdf) |
|
||||||
- **DevOps Roadmap** - [Roadmap Link](https://roadmap.sh/devops) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/devops.pdf) |
|
||||||
- **Computer Science Roadmap** - [Roadmap Link](https://roadmap.sh/computer-science) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/computer-science.pdf) |
|
||||||
- **QA Roadmap** - [Roadmap Link](https://roadmap.sh/qa) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/qa.pdf) |
|
||||||
- **ASP.NET Core Roadmap** - [Roadmap Link](https://roadmap.sh/aspnet-core) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/aspnet-core.pdf) |
|
||||||
- **Flutter Roadmap** - [Roadmap Link](https://roadmap.sh/flutter) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/flutter.pdf) |
|
||||||
- **Go Roadmap** - [Roadmap Link](https://roadmap.sh/golang) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/golang.pdf) |
|
||||||
- **Software Architect Roadmap** - [Roadmap Link](https://roadmap.sh/software-architect) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/software-architect.pdf) |
|
||||||
- **Software Design and Architecture Roadmap** - [Roadmap Link](https://roadmap.sh/software-design-architecture) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/software-design-architecture.pdf) |
|
||||||
- **JavaScript Roadmap** - [Roadmap Link](https://roadmap.sh/javascript) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/javascript.pdf) |
|
||||||
- **Node.js Roadmap** - [Roadmap Link](https://roadmap.sh/nodejs) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/nodejs.pdf) |
|
||||||
- **TypeScript Roadmap** - [Roadmap Link](https://roadmap.sh/typescript) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/typescript.pdf) |
|
||||||
- **GraphQL Roadmap** - [Roadmap Link](https://roadmap.sh/graphql) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/graphql.pdf) |
|
||||||
- **Angular Roadmap** - [Roadmap Link](https://roadmap.sh/angular) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/angular.pdf) |
|
||||||
- **React Roadmap** - [Roadmap Link](https://roadmap.sh/react) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/react.pdf) |
|
||||||
- **Vue Roadmap** - [Roadmap Link](https://roadmap.sh/vue) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/vue.pdf) |
|
||||||
- **Design System Roadmap** - [Roadmap Link](https://roadmap.sh/design-system) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/design-system.pdf) |
|
||||||
- **Blockchain Roadmap** - [Roadmap Link](https://roadmap.sh/blockchain) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/blockchain.pdf) |
|
||||||
- **Java Roadmap** - [Roadmap Link](https://roadmap.sh/java) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/java.pdf) |
|
||||||
- **Spring Boot Roadmap** - [Roadmap Link](https://roadmap.sh/spring-boot) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/spring-boot.pdf) |
|
||||||
- **Python Roadmap** - [Roadmap Link](https://roadmap.sh/python) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/python.pdf) |
|
||||||
- **System Design** - [Roadmap Link](https://roadmap.sh/system-design) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/system-design.pdf) |
|
||||||
- **Kubernetes** - [Roadmap Link](https://roadmap.sh/kubernetes) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/kubernetes.pdf) |
|
||||||
- **Cyber Security** - [Roadmap Link](https://roadmap.sh/cyber-security) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/cyber-security.pdf) |
|
||||||
- **MongoDB** - [Roadmap Link](https://roadmap.sh/mongodb) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/mongodb.pdf) |
|
||||||
- **UX Design** - [Roadmap Link](https://roadmap.sh/ux-design) / [PDF Link](https://roadmap.sh/pdfs/roadmaps/ux-design.pdf) |
|
||||||
|
|
||||||
Here is the list of PDF links for each of the best practices: |
|
||||||
|
|
||||||
- **Frontend Performance** - [Best Practices Link](https://roadmap.sh/best-practices/frontend-performance) / [PDF Link](https://roadmap.sh/pdfs/best-practices/frontend-performance.pdf) |
|
||||||
- **API Security** - [Best Practices Link](https://roadmap.sh/best-practices/api-security) / [PDF Link](https://roadmap.sh/pdfs/best-practices/api-security.pdf) |
|
||||||
- **Amazon Web Services (AWS)** - [Best Practices Link](https://roadmap.sh/best-practices/aws) / [PDF Link](https://roadmap.sh/pdfs/best-practices/aws.pdf) |
|
@ -0,0 +1,23 @@ |
|||||||
|
--- |
||||||
|
import ResetPasswordForm from '../components/AuthenticationFlow/ResetPasswordForm'; |
||||||
|
import SettingLayout from '../layouts/SettingLayout.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout title='Reset Password'> |
||||||
|
<div class='container'> |
||||||
|
<div |
||||||
|
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20' |
||||||
|
> |
||||||
|
<div class='mb-2 text-left sm:mb-5 sm:text-center'> |
||||||
|
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'> |
||||||
|
Reset Password |
||||||
|
</h1> |
||||||
|
<p class='mb-3 text-base leading-6 text-gray-600'> |
||||||
|
Enter and confirm your new password below. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<ResetPasswordForm client:load /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</SettingLayout> |
@ -0,0 +1,11 @@ |
|||||||
|
--- |
||||||
|
import UpdatePasswordForm from '../../components/Setting/UpdatePasswordForm'; |
||||||
|
import SettingSidebar from '../../components/Setting/SettingSidebar.astro'; |
||||||
|
import SettingLayout from '../../layouts/SettingLayout.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout title='Change Password' description=''> |
||||||
|
<SettingSidebar pageUrl='change-password' name='Change Password'> |
||||||
|
<UpdatePasswordForm client:load /> |
||||||
|
</SettingSidebar> |
||||||
|
</SettingLayout> |
@ -0,0 +1,11 @@ |
|||||||
|
--- |
||||||
|
import SettingSidebar from '../../components/Setting/SettingSidebar.astro'; |
||||||
|
import { UpdateProfileForm } from '../../components/Setting/UpdateProfileForm'; |
||||||
|
import SettingLayout from '../../layouts/SettingLayout.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout title='Update Profile'> |
||||||
|
<SettingSidebar pageUrl='profile' name='Profile'> |
||||||
|
<UpdateProfileForm client:load /> |
||||||
|
</SettingSidebar> |
||||||
|
</SettingLayout> |
@ -1,63 +1,49 @@ |
|||||||
--- |
--- |
||||||
import CaptchaFields from '../components/Captcha/CaptchaFields.astro'; |
import Divider from '../components/AuthenticationFlow/Divider.astro'; |
||||||
import CaptchaScripts from '../components/Captcha/CaptchaScripts.astro'; |
import GoogleLogin from '../components/Login/GoogleLogin.astro'; |
||||||
import BaseLayout from '../layouts/BaseLayout.astro'; |
import EmailSignupForm from '../components/AuthenticationFlow/EmailSignupForm'; |
||||||
|
import SettingLayout from '../layouts/SettingLayout.astro'; |
||||||
|
import { GitHubButton } from '../components/AuthenticationFlow/GitHubButton'; |
||||||
|
import { GoogleButton } from '../components/AuthenticationFlow/GoogleButton'; |
||||||
--- |
--- |
||||||
|
|
||||||
<BaseLayout |
<SettingLayout |
||||||
title='Signup - roadmap.sh' |
title='Signup - roadmap.sh' |
||||||
description='Register yourself to receive occasional emails about new roadmaps, updates, guides and videos' |
description='Create an account to track your progress, showcase your skillset' |
||||||
permalink={'/signup'} |
permalink={'/signup'} |
||||||
noIndex={true} |
noIndex={true} |
||||||
> |
> |
||||||
<div class='container'> |
<div class='container'> |
||||||
<div |
<div |
||||||
class='py-12 sm:py-0 sm:min-h-[550px] sm:max-w-[400px] mx-auto flex items-start sm:items-center flex-col justify-start sm:justify-center' |
class='mx-auto flex flex-col items-start justify-start pb-28 pt-10 sm:max-w-[400px] sm:items-center sm:justify-center sm:pt-20' |
||||||
> |
> |
||||||
<div class='mb-2 sm:mb-5 text-left sm:text-center'> |
<div class='mb-2 text-left sm:mb-5 sm:text-center'> |
||||||
<h1 class='text-3xl sm:text-5xl font-semibold mb-2 sm:mb-4'>Signup</h1> |
<h1 class='mb-2 text-3xl font-semibold sm:mb-5 sm:text-5xl'>Sign Up</h1> |
||||||
<p class='hidden sm:block text-md text-gray-600'> |
<p class='mb-3 hidden text-base leading-6 text-gray-600 sm:block'> |
||||||
Register yourself to receive occasional emails about new roadmaps, updates, guides and videos |
Create an account to track your progress, showcase your skill-set and |
||||||
|
be a part of the community. |
||||||
</p> |
</p> |
||||||
<p class='text-sm block sm:hidden text-gray-600'> |
<p class='mb-3 block text-sm text-gray-600 sm:hidden'> |
||||||
Register yourself for occasional updates about roadmaps, guides and videos. |
Create an account to track your progress, showcase your skill-set and |
||||||
|
be a part of the community. videos. |
||||||
</p> |
</p> |
||||||
</div> |
</div> |
||||||
|
|
||||||
<form |
<div class='flex w-full flex-col items-stretch gap-2'> |
||||||
action='https://news.roadmap.sh/subscribe' |
<GitHubButton client:load /> |
||||||
method='POST' |
<GoogleButton client:load /> |
||||||
accept-charset='utf-8' |
</div> |
||||||
class='w-full' |
|
||||||
captcha-form |
|
||||||
> |
|
||||||
<input type='hidden' name='gdpr' value='true' /> |
|
||||||
|
|
||||||
<input |
|
||||||
type='email' |
|
||||||
required |
|
||||||
name='email' |
|
||||||
id='email' |
|
||||||
autofocus |
|
||||||
class='mt-1 block w-full mb-2 border-2 rounded-md py-2 sm:py-3 px-3 sm:px-3.5 text-md' |
|
||||||
placeholder='Enter your email' |
|
||||||
/> |
|
||||||
|
|
||||||
<CaptchaFields /> |
<Divider /> |
||||||
|
|
||||||
<input type='hidden' name='list' value='tTqz1w7nexY3cWDpLnI88Q' /> |
<EmailSignupForm client:load /> |
||||||
<input type='hidden' name='subform' value='yes' /> |
|
||||||
|
|
||||||
<button |
<div class='mt-6 text-center text-sm text-slate-600'> |
||||||
type='submit' |
Already have an account? <a |
||||||
name='submit' |
href='/login' |
||||||
class='bg-gradient-to-r from-blue-500 to-blue-700 hover:from-blue-600 hover:to-blue-800 text-white py-2 sm:py-2.5 sm:px-5 rounded-md w-full text-md' |
class='font-medium text-blue-700 hover:text-blue-600'>Login</a |
||||||
> |
> |
||||||
Subscribe |
</div> |
||||||
</button> |
|
||||||
</form> |
|
||||||
</div> |
</div> |
||||||
</div> |
</div> |
||||||
|
</SettingLayout> |
||||||
<CaptchaScripts slot='after-footer' /> |
|
||||||
</BaseLayout> |
|
||||||
|
@ -0,0 +1,10 @@ |
|||||||
|
--- |
||||||
|
import SettingLayout from '../layouts/SettingLayout.astro'; |
||||||
|
import { VerificationEmailMessage } from '../components/AuthenticationFlow/VerificationEmailMessage'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout title='Verify Email'> |
||||||
|
<section class='container py-8 sm:py-20'> |
||||||
|
<VerificationEmailMessage client:load /> |
||||||
|
</section> |
||||||
|
</SettingLayout> |
@ -0,0 +1,10 @@ |
|||||||
|
--- |
||||||
|
import { TriggerVerifyAccount } from '../components/AuthenticationFlow/TriggerVerifyAccount'; |
||||||
|
import SettingLayout from '../layouts/SettingLayout.astro'; |
||||||
|
--- |
||||||
|
|
||||||
|
<SettingLayout title='Verify account'> |
||||||
|
<div class='container py-16'> |
||||||
|
<TriggerVerifyAccount client:load /> |
||||||
|
</div> |
||||||
|
</SettingLayout> |
@ -0,0 +1,3 @@ |
|||||||
|
import { atom } from 'nanostores'; |
||||||
|
|
||||||
|
export const pageLoadingMessage = atom(''); |
@ -1,3 +1,7 @@ |
|||||||
{ |
{ |
||||||
"extends": "astro/tsconfigs/strict" |
"extends": "astro/tsconfigs/strict", |
||||||
|
"compilerOptions": { |
||||||
|
"jsx": "react-jsx", |
||||||
|
"jsxImportSource": "preact" |
||||||
|
} |
||||||
} |
} |
||||||
|