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