diff --git a/.eslintrc.js b/.eslintrc.js index 13d10867..4b07ff99 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,8 +34,9 @@ module.exports = { varsIgnorePattern: '^log$' } ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-non-null-assertion': 'warn', + // TODO: Remove any usage and enable + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', // solid-js fix 'import/no-unresolved': [2, { ignore: ['solid-js/'] }] diff --git a/src/components/Author/Card.module.scss b/src/components/Author/Card.module.scss index b0b4bb2c..1dece175 100644 --- a/src/components/Author/Card.module.scss +++ b/src/components/Author/Card.module.scss @@ -97,6 +97,7 @@ } } + a[href*='vk.cc/'], a[href*='vk.com/'] { &::before { background-image: url(/icons/vk-white.svg); diff --git a/src/components/Nav/AuthModal.tsx b/src/components/Nav/AuthModal.tsx deleted file mode 100644 index e26ad6b2..00000000 --- a/src/components/Nav/AuthModal.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { Show } from 'solid-js/web' -import { Icon } from './Icon' -import { createEffect, createSignal, onMount } from 'solid-js' -import './AuthModal.scss' -import { Form } from 'solid-js-form' -import { t } from '../../utils/intl' -import { hideModal, useModalStore } from '../../stores/ui' -import { useAuthStore, signIn, register } from '../../stores/auth' -import { useValidator } from '../../utils/validators' -import { baseUrl } from '../../graphql/publicGraphQLClient' -import { ApiError } from '../../utils/apiClient' -import { handleClientRouteLinkClick } from '../../stores/router' - -type AuthMode = 'sign-in' | 'sign-up' | 'forget' | 'reset' | 'resend' | 'password' - -const statuses: { [key: string]: string } = { - 'email not found': 'No such account, please try to register', - 'invalid password': 'Invalid password', - 'invalid code': 'Invalid code', - 'unknown error': 'Unknown error' -} - -const titles = { - 'sign-up': t('Create account'), - 'sign-in': t('Enter the Discours'), - forget: t('Forgot password?'), - reset: t('Please, confirm your email to finish'), - resend: t('Resend code'), - password: t('Enter your new password') -} - -// const isProperEmail = (email) => email && email.length > 5 && email.includes('@') && email.includes('.') - -// 3rd party provider auth handler -const oauth = (provider: string): void => { - const popup = window.open(`${baseUrl}/oauth/${provider}`, provider, 'width=740, height=420') - popup?.focus() - hideModal() -} - -// FIXME !!! -// eslint-disable-next-line sonarjs/cognitive-complexity -export default (props: { code?: string; mode?: AuthMode }) => { - const { session } = useAuthStore() - const [handshaking] = createSignal(false) - const { getModal } = useModalStore() - const [authError, setError] = createSignal('') - const [mode, setMode] = createSignal('sign-in') - const [validation, setValidation] = createSignal({}) - const [initial, setInitial] = createSignal({}) - let emailElement: HTMLInputElement | undefined - let pass2Element: HTMLInputElement | undefined - let passElement: HTMLInputElement | undefined - let codeElement: HTMLInputElement | undefined - - // FIXME: restore logic - // const usedEmails = {} - // const checkEmailAsync = async (email: string) => { - // const handleChecked = (x: boolean) => { - // if (x && mode() === 'sign-up') setError(t('We know you, please try to sign in')) - // if (!x && mode() === 'sign-in') setError(t('No such account, please try to register')) - // usedEmails[email] = x - // } - // if (email in usedEmails) { - // handleChecked(usedEmails[email]) - // } else if (isProperEmail(email)) { - // const { error, data } = await apiClient.q(authCheck, { email }, true) - // if (error) setError(error.message) - // if (data) handleChecked(data.isEmailUsed) - // } - // } - - // let checkEmailTimeout - // createEffect(() => { - // const email = emailElement?.value - // if (isProperEmail(email)) { - // if (checkEmailTimeout) clearTimeout(checkEmailTimeout) - // checkEmailTimeout = setTimeout(checkEmailAsync, 3000) // after 3 secs - // } - // }, [emailElement?.value]) - - // switching initial values and validatiors - const setupValidators = () => { - const [vs, ini] = useValidator(mode()) - setValidation(vs) - setInitial(ini) - } - - onMount(setupValidators) - - const resetError = () => { - setError('') - } - - const changeMode = (newMode: AuthMode) => { - setMode(newMode) - resetError() - } - - // local auth handler - const localAuth = async () => { - console.log('[auth] native account processing') - switch (mode()) { - case 'sign-in': - try { - await signIn({ email: emailElement?.value, password: passElement?.value }) - } catch (error) { - if (error instanceof ApiError) { - if (error.code === 'email_not_confirmed') { - setError(t('Please, confirm email')) - return - } - - if (error.code === 'user_not_found') { - setError(t('Something went wrong, check email and password')) - return - } - } - - setError(error.message) - } - - break - case 'sign-up': - if (pass2Element?.value !== passElement?.value) { - setError(t('Passwords are not equal')) - } else { - await register({ - email: emailElement?.value, - password: passElement?.value - }) - } - break - case 'reset': - // send reset-code to login with email - console.log('[auth] reset code: ' + codeElement?.value) - // TODO: authReset(codeElement?.value) - break - case 'resend': - // TODO: authResend(emailElement?.value) - break - case 'forget': - // shows forget mode of auth-modal - if (pass2Element?.value !== passElement?.value) { - setError(t('Passwords are not equal')) - } else { - // TODO: authForget(passElement?.value) - } - break - default: - console.log('[auth] unknown auth mode', mode()) - } - } - - // FIXME move to handlers - createEffect(() => { - if (session()?.user?.slug && getModal() === 'auth') { - // hiding itself if finished - console.log('[auth] success, hiding modal') - hideModal() - } else if (session()?.error) { - console.log('[auth] failure, showing error') - setError(t(statuses[session().error || 'unknown error'])) - } else { - console.log('[auth] session', session()) - } - }) - - return ( -
-
-
-

{t('Discours')}

-

{t(`Join the global community of authors!`)}

-

- {t( - 'Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine' - )} - .  - {t('New stories every day and even more!')} -

-

- {t('By signing up you agree with our')}{' '} - { - hideModal() - handleClientRouteLinkClick(event) - }} - > - {t('terms of use')} - - , {t('personal data usage and email notifications')}. -

-
-
-
-
{ - console.log('[auth] form values', form.values) - }} - > -
-

{titles[mode()]}

-
- - {t('Enter the code or click the link from email to confirm')} - - } - > - {t('Everything is ok, please give us your email address')} - -
- -
-
    -
  • {authError()}
  • -
-
-
- {/*FIXME*/} - {/**/} - {/*
*/} - {/* */} - {/* */} - {/*
*/} - {/*
*/} - -
- - -
-
- -
- - -
-
- -
- - -
-
- -
- - -
-
-
- -
- - - - - - -
-
- changeMode('sign-in')}> - {t('I have an account')} - -
-
- changeMode('sign-up')}> - {t('I have no account yet')} - -
-
- changeMode('sign-in')}> - {t('I know the password')} - -
-
- changeMode('resend')}> - {t('Resend code')} - -
-
-
-
-
-
- ) -} diff --git a/src/components/Nav/AuthModal.scss b/src/components/Nav/AuthModal/AuthModal.module.scss similarity index 62% rename from src/components/Nav/AuthModal.scss rename to src/components/Nav/AuthModal/AuthModal.module.scss index 13b66e81..3a89155e 100644 --- a/src/components/Nav/AuthModal.scss +++ b/src/components/Nav/AuthModal/AuthModal.module.scss @@ -12,12 +12,12 @@ } } -.view--sign-up { - .auth-image { +.signUp { + .authImage { order: 2; } - .auth-image::before { + .authImage::before { background: linear-gradient(0deg, rgb(20 20 20 / 80%), rgb(20 20 20 / 80%)); content: ''; height: 100%; @@ -27,12 +27,12 @@ width: 100%; } - & ~ .close-control { + & ~ :global(.close-control) { filter: invert(1); } } -.auth-image { +.authImage { background: #141414 url('/auth-page.jpg') center no-repeat; background-size: cover; color: #fff; @@ -55,8 +55,8 @@ } } -.auth-image__text { - display: none; +.authImageText { + display: flex; flex-direction: column; justify-content: space-between; position: relative; @@ -70,22 +70,22 @@ color: rgb(255 255 255 / 70%); } } + + &.hidden { + display: none; + } } -.auth-image__text.show { - display: flex; -} - -.auth-benefits { +.authBenefits { flex: 1; } -.disclamer { +.disclaimer { color: #9fa1a7; @include font-size(1.2rem); } -.auth-actions { +.authActions { @include font-size(1.5rem); margin-top: 1.6rem; @@ -107,84 +107,30 @@ } } -.submitbtn { +.submitButton { display: block; font-weight: 700; padding: 1.6rem; width: 100%; } -.social-provider { - border-bottom: 1px solid #141414; - border-top: 1px solid #141414; - margin-top: 1em; - padding: 0.8em 0 1em; -} - -.social { - background-color: white !important; - display: flex; - margin: 0 -5px; - - > * { - background-color: #f7f7f7; - cursor: pointer; - flex: 1; - margin: 0 5px; - padding: 0.5em; - text-align: center; - } - - img { - height: 1.4em; - max-width: 1.8em; - vertical-align: middle; - width: auto; - } - - a { - border: none; - } - - .github-auth:hover { - img { - filter: invert(1); - } - } -} - -.auth-control { +.authControl { color: $link-color; margin-top: 1em; text-align: center; - - div { - display: none; - } - - .show { - display: block; - } } -.auth-link { +.authLink { cursor: pointer; } -.providers-text { - @include font-size(1.5rem); - - margin-bottom: 1em; - text-align: center; -} - -.auth-subtitle { +.authSubtitle { @include font-size(1.5rem); margin: 1em; } -.auth-info { +.authInfo { min-height: 5em; font-weight: 400; font-size: smaller; @@ -192,8 +138,39 @@ .warn { color: #a00; } +} - .info { - color: gray; +.validationError { + position: relative; + top: -8px; + font-size: 12px; + line-height: 16px; + margin-bottom: 8px; + + /* Red/500 */ + color: #d00820; + + a { + color: #d00820; + border-color: #d00820; + + &:hover { + color: white; + border-color: black; + } } } + +.title { + font-size: 26px; + line-height: 32px; + font-weight: 700; + color: #141414; + margin-bottom: 16px; +} + +.text { + font-size: 15px; + line-height: 24px; + margin-bottom: 52px; +} diff --git a/src/components/Nav/AuthModal/EmailConfirm.tsx b/src/components/Nav/AuthModal/EmailConfirm.tsx new file mode 100644 index 00000000..1fd1c7aa --- /dev/null +++ b/src/components/Nav/AuthModal/EmailConfirm.tsx @@ -0,0 +1,44 @@ +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { t } from '../../../utils/intl' +import { hideModal } from '../../../stores/ui' +import { createMemo, onMount, Show } from 'solid-js' +import { useRouter } from '../../../stores/router' +import { confirmEmail, useAuthStore } from '../../../stores/auth' + +type ConfirmEmailSearchParams = { + token: string +} + +export const EmailConfirm = () => { + const { session } = useAuthStore() + + const confirmedEmail = createMemo(() => session()?.user?.email || '') + + const { searchParams } = useRouter() + + onMount(async () => { + const token = searchParams().token + try { + await confirmEmail(token) + } catch (error) { + console.log(error) + } + }) + + return ( +
+
{t('Hooray! Welcome!')}
+ +
+ {t("You've confirmed email")} {confirmedEmail()} +
+
+
+ +
+
+ ) +} diff --git a/src/components/Nav/AuthModal/ForgotPasswordForm.tsx b/src/components/Nav/AuthModal/ForgotPasswordForm.tsx new file mode 100644 index 00000000..173727eb --- /dev/null +++ b/src/components/Nav/AuthModal/ForgotPasswordForm.tsx @@ -0,0 +1,98 @@ +import { Show } from 'solid-js/web' +import { t } from '../../../utils/intl' +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { createSignal, JSX } from 'solid-js' +import { useRouter } from '../../../stores/router' +import { email, setEmail } from './sharedLogic' +import type { AuthModalSearchParams } from './types' +import { isValidEmail } from './validators' + +type FormFields = { + email: string +} + +type ValidationErrors = Partial> + +export const ForgotPasswordForm = () => { + const { changeSearchParam } = useRouter() + + const handleEmailInput = (newEmail: string) => { + setValidationErrors(({ email: _notNeeded, ...rest }) => rest) + setEmail(newEmail) + } + + const [submitError, setSubmitError] = createSignal('') + const [isSubmitting, setIsSubmitting] = createSignal(false) + const [validationErrors, setValidationErrors] = createSignal({}) + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + setSubmitError('') + + const newValidationErrors: ValidationErrors = {} + + if (!email()) { + newValidationErrors.email = t('Please enter email') + } else if (!isValidEmail(email())) { + newValidationErrors.email = t('Invalid email') + } + + setValidationErrors(newValidationErrors) + + const isValid = Object.keys(newValidationErrors).length === 0 + + if (!isValid) { + return + } + + setIsSubmitting(true) + + try { + // TODO: send mail with link to new password form + } catch (error) { + setSubmitError(error.message) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

{t('Forgot password?')}

+
{t('Everything is ok, please give us your email address')}
+ +
+
    +
  • {submitError()}
  • +
+
+
+
+ handleEmailInput(event.currentTarget.value)} + /> + + +
+ +
+ +
+
+ changeSearchParam('mode', 'login')}> + {t('I know the password')} + +
+
+ ) +} diff --git a/src/components/Nav/AuthModal/LoginForm.tsx b/src/components/Nav/AuthModal/LoginForm.tsx new file mode 100644 index 00000000..c683b223 --- /dev/null +++ b/src/components/Nav/AuthModal/LoginForm.tsx @@ -0,0 +1,171 @@ +import { Show } from 'solid-js/web' +import { t } from '../../../utils/intl' +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { SocialProviders } from './SocialProviders' +import { signIn, signSendLink } from '../../../stores/auth' +import { ApiError } from '../../../utils/apiClient' +import { createSignal } from 'solid-js' +import { isValidEmail } from './validators' +import { email, setEmail } from './sharedLogic' +import { useRouter } from '../../../stores/router' +import type { AuthModalSearchParams } from './types' +import { hideModal } from '../../../stores/ui' + +type FormFields = { + email: string + password: string +} + +type ValidationErrors = Partial> + +export const LoginForm = () => { + const [submitError, setSubmitError] = createSignal('') + const [isSubmitting, setIsSubmitting] = createSignal(false) + const [validationErrors, setValidationErrors] = createSignal({}) + // TODO: better solution for interactive error messages + const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false) + const [isLinkSent, setIsLinkSent] = createSignal(false) + + const { changeSearchParam } = useRouter() + + const [password, setPassword] = createSignal('') + + const handleEmailInput = (newEmail: string) => { + setValidationErrors(({ email: _notNeeded, ...rest }) => rest) + setEmail(newEmail) + } + + const handlePasswordInput = (newPassword: string) => { + setValidationErrors(({ password: _notNeeded, ...rest }) => rest) + setPassword(newPassword) + } + + const handleSendLinkAgainClick = (event: Event) => { + event.preventDefault() + setIsEmailNotConfirmed(false) + setSubmitError('') + setIsLinkSent(true) + signSendLink({ email: email() }) + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + setIsLinkSent(false) + setSubmitError('') + + const newValidationErrors: ValidationErrors = {} + + if (!email()) { + newValidationErrors.email = t('Please enter email') + } else if (!isValidEmail(email())) { + newValidationErrors.email = t('Invalid email') + } + + if (!password()) { + newValidationErrors.password = t('Please enter password') + } + + if (Object.keys(newValidationErrors).length > 0) { + setValidationErrors(newValidationErrors) + return + } + + setIsSubmitting(true) + + try { + await signIn({ email: email(), password: password() }) + hideModal() + } catch (error) { + if (error instanceof ApiError) { + if (error.code === 'email_not_confirmed') { + setSubmitError(t('Please, confirm email')) + setIsEmailNotConfirmed(true) + return + } + + if (error.code === 'user_not_found') { + setSubmitError(t('Something went wrong, check email and password')) + return + } + } + + setSubmitError(error.message) + } finally { + setIsSubmitting(false) + } + } + + return ( +
+

{t('Enter the Discours')}

+ +
+
{submitError()}
+ + + {t('Send link again')} + + +
+
+ +
{t('Link sent, check your email')}
+
+
+ handleEmailInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().email}
+
+
+ handlePasswordInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().password}
+
+
+ +
+ + + + +
+ changeSearchParam('mode', 'register')}> + {t('I have no account yet')} + +
+ + ) +} diff --git a/src/components/Nav/AuthModal/RegisterForm.tsx b/src/components/Nav/AuthModal/RegisterForm.tsx new file mode 100644 index 00000000..06adadb7 --- /dev/null +++ b/src/components/Nav/AuthModal/RegisterForm.tsx @@ -0,0 +1,206 @@ +import { Show } from 'solid-js/web' +import type { JSX } from 'solid-js' +import { t } from '../../../utils/intl' +import styles from './AuthModal.module.scss' +import { clsx } from 'clsx' +import { SocialProviders } from './SocialProviders' +import { checkEmail, register, useAuthStore } from '../../../stores/auth' +import { createSignal } from 'solid-js' +import { isValidEmail } from './validators' +import { ApiError } from '../../../utils/apiClient' +import { email, setEmail } from './sharedLogic' +import { useRouter } from '../../../stores/router' +import type { AuthModalSearchParams } from './types' +import { hideModal } from '../../../stores/ui' + +type FormFields = { + name: string + email: string + password: string +} + +type ValidationErrors = Partial> + +export const RegisterForm = () => { + const { changeSearchParam } = useRouter() + + const { emailChecks } = useAuthStore() + + const [submitError, setSubmitError] = createSignal('') + const [name, setName] = createSignal('') + const [password, setPassword] = createSignal('') + const [isSubmitting, setIsSubmitting] = createSignal(false) + const [isSuccess, setIsSuccess] = createSignal(false) + const [validationErrors, setValidationErrors] = createSignal({}) + + const handleEmailInput = (newEmail: string) => { + setValidationErrors(({ email: _notNeeded, ...rest }) => rest) + setEmail(newEmail) + } + + const handleEmailBlur = () => { + if (isValidEmail(email())) { + checkEmail(email()) + } + } + + const handlePasswordInput = (newPassword: string) => { + setValidationErrors(({ password: _notNeeded, ...rest }) => rest) + setPassword(newPassword) + } + + const handleNameInput = (newPasswordCopy: string) => { + setValidationErrors(({ name: _notNeeded, ...rest }) => rest) + setName(newPasswordCopy) + } + + const handleSubmit = async (event: Event) => { + event.preventDefault() + + setSubmitError('') + + const newValidationErrors: ValidationErrors = {} + + if (!name()) { + newValidationErrors.name = t('Please enter a name to sign your comments and publication') + } + + if (!email()) { + newValidationErrors.email = t('Please enter email') + } else if (!isValidEmail(email())) { + newValidationErrors.email = t('Invalid email') + } + + if (!password()) { + newValidationErrors.password = t('Please enter password') + } + + setValidationErrors(newValidationErrors) + + const emailCheckResult = await checkEmail(email()) + + const isValid = Object.keys(newValidationErrors).length === 0 && !emailCheckResult + + if (!isValid) { + return + } + + setIsSubmitting(true) + + try { + await register({ + name: name(), + email: email(), + password: password() + }) + + setIsSuccess(true) + } catch (error) { + if (error instanceof ApiError && error.code === 'user_already_exists') { + return + } + + setSubmitError(error.message) + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + +
+

{t('Create account')}

+ +
+
    +
  • {submitError()}
  • +
+
+
+
+ handleNameInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().name}
+
+
+ handleEmailInput(event.currentTarget.value)} + onBlur={handleEmailBlur} + /> + +
+ +
{validationErrors().email}
+
+ + + +
+ handlePasswordInput(event.currentTarget.value)} + /> + +
+ +
{validationErrors().password}
+
+ +
+ +
+ + + +
+ changeSearchParam('mode', 'login')}> + {t('I have an account')} + +
+ +
+ +
{t('Almost done! Check your email.')}
+
{t("We've sent you a message with a link to enter our website.")}
+
+ +
+
+ + ) +} diff --git a/src/components/Nav/AuthModal/SocialProviders.module.scss b/src/components/Nav/AuthModal/SocialProviders.module.scss new file mode 100644 index 00000000..b16a0b3a --- /dev/null +++ b/src/components/Nav/AuthModal/SocialProviders.module.scss @@ -0,0 +1,45 @@ +.container { + border-bottom: 1px solid #141414; + border-top: 1px solid #141414; + margin-top: 1em; + padding: 0.8em 0 1em; +} + +.text { + @include font-size(1.5rem); + + margin-bottom: 1em; + text-align: center; +} + +.social { + background-color: white; + display: flex; + margin: 0 -5px; + + > * { + background-color: #f7f7f7; + cursor: pointer; + flex: 1; + margin: 0 5px; + padding: 0.5em; + text-align: center; + } + + img { + height: 1em; + max-width: 1.8em; + vertical-align: middle; + width: auto; + } + + a { + border: none; + } + + .githubAuth:hover { + img { + filter: invert(1); + } + } +} diff --git a/src/components/Nav/AuthModal/SocialProviders.tsx b/src/components/Nav/AuthModal/SocialProviders.tsx new file mode 100644 index 00000000..1daefd67 --- /dev/null +++ b/src/components/Nav/AuthModal/SocialProviders.tsx @@ -0,0 +1,42 @@ +import { t } from '../../../utils/intl' +import { Icon } from '../Icon' +import { hideModal } from '../../../stores/ui' + +import styles from './SocialProviders.module.scss' +import { apiBaseUrl } from '../../../utils/config' + +type Provider = 'facebook' | 'google' | 'vk' | 'github' + +// 3rd party provider auth handler +const handleSocialAuthLinkClick = (event: MouseEvent, provider: Provider): void => { + event.preventDefault() + const popup = window.open(`${apiBaseUrl}/oauth/${provider}`, provider, 'width=740, height=420') + popup?.focus() + hideModal() +} + +export const SocialProviders = () => { + return ( + + ) +} diff --git a/src/components/Nav/AuthModal/index.tsx b/src/components/Nav/AuthModal/index.tsx new file mode 100644 index 00000000..f0a773d1 --- /dev/null +++ b/src/components/Nav/AuthModal/index.tsx @@ -0,0 +1,76 @@ +import { Dynamic } from 'solid-js/web' +import { Component, createEffect, createMemo } from 'solid-js' +import { t } from '../../../utils/intl' +import { hideModal } from '../../../stores/ui' +import { handleClientRouteLinkClick, useRouter } from '../../../stores/router' +import { clsx } from 'clsx' +import styles from './AuthModal.module.scss' +import { LoginForm } from './LoginForm' +import { RegisterForm } from './RegisterForm' +import { ForgotPasswordForm } from './ForgotPasswordForm' +import { EmailConfirm } from './EmailConfirm' +import type { AuthModalMode, AuthModalSearchParams } from './types' + +const AUTH_MODAL_MODES: Record = { + login: LoginForm, + register: RegisterForm, + 'forgot-password': ForgotPasswordForm, + 'confirm-email': EmailConfirm +} + +export const AuthModal = () => { + let rootRef: HTMLDivElement + + const { searchParams } = useRouter() + + const mode = createMemo(() => { + return AUTH_MODAL_MODES[searchParams().mode] ? searchParams().mode : 'login' + }) + + createEffect((oldMode) => { + if (oldMode !== mode()) { + rootRef?.querySelector('input')?.focus() + } + }, null) + + return ( +
+
+
+

{t('Discours')}

+

{t(`Join the global community of authors!`)}

+

+ {t( + 'Get to know the most intelligent people of our time, edit and discuss the articles, share your expertise, rate and decide what to publish in the magazine' + )} + .  + {t('New stories every day and even more!')} +

+

+ {t('By signing up you agree with our')}{' '} + { + hideModal() + handleClientRouteLinkClick(event) + }} + > + {t('terms of use')} + + , {t('personal data usage and email notifications')}. +

+
+
+
+ +
+
+ ) +} diff --git a/src/components/Nav/AuthModal/sharedLogic.tsx b/src/components/Nav/AuthModal/sharedLogic.tsx new file mode 100644 index 00000000..1d956b0e --- /dev/null +++ b/src/components/Nav/AuthModal/sharedLogic.tsx @@ -0,0 +1,5 @@ +import { createSignal } from 'solid-js' + +const [email, setEmail] = createSignal('') + +export { email, setEmail } diff --git a/src/components/Nav/AuthModal/types.ts b/src/components/Nav/AuthModal/types.ts new file mode 100644 index 00000000..e7aeed0e --- /dev/null +++ b/src/components/Nav/AuthModal/types.ts @@ -0,0 +1,5 @@ +export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password' + +export type AuthModalSearchParams = { + mode: AuthModalMode +} diff --git a/src/components/Nav/AuthModal/validators.ts b/src/components/Nav/AuthModal/validators.ts new file mode 100644 index 00000000..a4950dff --- /dev/null +++ b/src/components/Nav/AuthModal/validators.ts @@ -0,0 +1,7 @@ +export const isValidEmail = (email: string) => { + if (!email) { + return false + } + + return email.includes('@') && email.includes('.') && email.length > 5 +} diff --git a/src/components/Nav/Header.tsx b/src/components/Nav/Header.tsx index 8a4e46f8..d5bc84b7 100644 --- a/src/components/Nav/Header.tsx +++ b/src/components/Nav/Header.tsx @@ -3,7 +3,7 @@ import Private from './Private' import Notifications from './Notifications' import { Icon } from './Icon' import { Modal } from './Modal' -import AuthModal from './AuthModal' +import { AuthModal } from './AuthModal' import { t } from '../../utils/intl' import { useModalStore, showModal, useWarningsStore } from '../../stores/ui' import { useAuthStore } from '../../stores/auth' @@ -23,10 +23,6 @@ const resources: { name: string; route: keyof Routes }[] = [ { name: t('topics'), route: 'topics' } ] -const handleEnterClick = () => { - showModal('auth') -} - type Props = { title?: string isHeaderFixed?: boolean @@ -40,24 +36,26 @@ export const Header = (props: Props) => { const [visibleWarnings, setVisibleWarnings] = createSignal(false) const [isSharePopupVisible, setIsSharePopupVisible] = createSignal(false) // stores - const { getWarnings } = useWarningsStore() + const { warnings } = useWarningsStore() const { session } = useAuthStore() - const { getModal } = useModalStore() + const { modal } = useModalStore() - const { getPage } = useRouter() + const { page } = useRouter() // methods const toggleWarnings = () => setVisibleWarnings(!visibleWarnings()) const toggleFixed = () => setFixed((oldFixed) => !oldFixed) // effects createEffect(() => { - document.body.classList.toggle('fixed', fixed() || getModal() !== null) + document.body.classList.toggle('fixed', fixed() || modal() !== null) }) // derived const authorized = createMemo(() => session()?.user?.slug) - const handleBellIconClick = () => { + const handleBellIconClick = (event: Event) => { + event.preventDefault() + if (!authorized()) { showModal('auth') return @@ -113,7 +111,7 @@ export const Header = (props: Props) => { > {(r) => ( -
  • +
  • {r.name} @@ -131,9 +129,9 @@ export const Header = (props: Props) => {
    @@ -148,7 +146,7 @@ export const Header = (props: Props) => { when={authorized()} fallback={ diff --git a/src/components/Nav/Modal.tsx b/src/components/Nav/Modal.tsx index f5d04763..bca4c2f7 100644 --- a/src/components/Nav/Modal.tsx +++ b/src/components/Nav/Modal.tsx @@ -1,4 +1,5 @@ -import { createEffect, createSignal, onMount, Show } from 'solid-js' +import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js' +import type { JSX } from 'solid-js' import { getLogger } from '../../utils/logger' import './Modal.scss' import { hideModal, useModalStore } from '../../stores/ui' @@ -7,26 +8,33 @@ const log = getLogger('modal') interface ModalProps { name: string - children: any + children: JSX.Element +} + +const keydownHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') hideModal() } export const Modal = (props: ModalProps) => { - const { getModal } = useModalStore() + const { modal } = useModalStore() - const wrapClick = (ev: Event) => { - if ((ev.target as HTMLElement).classList.contains('modalwrap')) hideModal() + const wrapClick = (event: { target: Element }) => { + if (event.target.classList.contains('modalwrap')) hideModal() } onMount(() => { - window.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') hideModal() + window.addEventListener('keydown', keydownHandler) + + onCleanup(() => { + window.removeEventListener('keydown', keydownHandler) }) }) const [visible, setVisible] = createSignal(false) + createEffect(() => { - setVisible(getModal() === props.name) - log.debug(`${props.name} is ${getModal() === props.name ? 'visible' : 'hidden'}`) + setVisible(modal() === props.name) + log.debug(`${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`) }) return ( diff --git a/src/components/Nav/Notifications.tsx b/src/components/Nav/Notifications.tsx index af8edc99..c7c28215 100644 --- a/src/components/Nav/Notifications.tsx +++ b/src/components/Nav/Notifications.tsx @@ -3,15 +3,15 @@ import { useWarningsStore } from '../../stores/ui' import { createMemo } from 'solid-js' export default () => { - const { getWarnings } = useWarningsStore() + const { warnings } = useWarningsStore() - const notSeen = createMemo(() => getWarnings().filter((warning) => !warning.seen)) + const notSeen = createMemo(() => warnings().filter((warning) => !warning.seen)) return ( 0}>
      - {(warning) =>
    • {warning.body}
    • }
      + {(warning) =>
    • {warning.body}
    • }
    diff --git a/src/components/Nav/Private.tsx b/src/components/Nav/Private.tsx index 42a5ceb3..2a3ad170 100644 --- a/src/components/Nav/Private.tsx +++ b/src/components/Nav/Private.tsx @@ -4,11 +4,11 @@ import { Icon } from './Icon' import styles from './Private.module.scss' import { useAuthStore } from '../../stores/auth' import { useRouter } from '../../stores/router' -import clsx from 'clsx' +import { clsx } from 'clsx' export default () => { const { session } = useAuthStore() - const { getPage } = useRouter() + const { page } = useRouter() return (
    @@ -21,7 +21,7 @@ export default () => {
    {/*FIXME: replace with route*/} -
    +
    @@ -29,7 +29,7 @@ export default () => {
    {/*FIXME: replace with route*/} -
    +
    diff --git a/src/components/Pages/ArticlePage.tsx b/src/components/Pages/ArticlePage.tsx index 357c5494..00e7d661 100644 --- a/src/components/Pages/ArticlePage.tsx +++ b/src/components/Pages/ArticlePage.tsx @@ -11,7 +11,7 @@ export const ArticlePage = (props: PageProps) => { const sortedArticles = props.article ? [props.article] : [] const slug = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Pages/AuthorPage.tsx b/src/components/Pages/AuthorPage.tsx index 1d159773..0eab1346 100644 --- a/src/components/Pages/AuthorPage.tsx +++ b/src/components/Pages/AuthorPage.tsx @@ -11,7 +11,7 @@ export const AuthorPage = (props: PageProps) => { const [isLoaded, setIsLoaded] = createSignal(Boolean(props.authorArticles) && Boolean(props.author)) const slug = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Pages/SearchPage.tsx b/src/components/Pages/SearchPage.tsx index ff636018..9ce346b1 100644 --- a/src/components/Pages/SearchPage.tsx +++ b/src/components/Pages/SearchPage.tsx @@ -10,7 +10,7 @@ export const SearchPage = (props: PageProps) => { const [isLoaded, setIsLoaded] = createSignal(Boolean(props.searchResults)) const q = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Pages/TopicPage.tsx b/src/components/Pages/TopicPage.tsx index a92e1d3f..7cbaa1b5 100644 --- a/src/components/Pages/TopicPage.tsx +++ b/src/components/Pages/TopicPage.tsx @@ -11,7 +11,7 @@ export const TopicPage = (props: PageProps) => { const [isLoaded, setIsLoaded] = createSignal(Boolean(props.authorArticles) && Boolean(props.author)) const slug = createMemo(() => { - const { getPage } = useRouter() + const { page: getPage } = useRouter() const page = getPage() diff --git a/src/components/Root.tsx b/src/components/Root.tsx index a770d97a..1827703b 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -1,8 +1,8 @@ // FIXME: breaks on vercel, research // import 'solid-devtools' -import { setLocale } from '../stores/ui' -import { Component, createEffect, createMemo } from 'solid-js' +import { MODALS, setLocale, showModal } from '../stores/ui' +import { Component, createEffect, createMemo, onMount } from 'solid-js' import { Routes, useRouter } from '../stores/router' import { Dynamic, isServer } from 'solid-js/web' import { getLogger } from '../utils/logger' @@ -27,6 +27,7 @@ import { ProjectsPage } from './Pages/about/ProjectsPage' import { TermsOfUsePage } from './Pages/about/TermsOfUsePage' import { ThanksPage } from './Pages/about/ThanksPage' import { CreatePage } from './Pages/CreatePage' +import { renewSession } from '../stores/auth' // TODO: lazy load // const HomePage = lazy(() => import('./Pages/HomePage')) @@ -50,6 +51,11 @@ import { CreatePage } from './Pages/CreatePage' const log = getLogger('root') +type RootSearchParams = { + modal: string + lang: string +} + const pagesMap: Record> = { create: CreatePage, home: HomePage, @@ -71,16 +77,23 @@ const pagesMap: Record> = { } export const Root = (props: PageProps) => { - const { getPage } = useRouter() + const { page, searchParams } = useRouter() - // log.debug({ route: getPage().route }) + createEffect(() => { + const modal = MODALS[searchParams().modal] + if (modal) { + showModal(modal) + } + }) + + onMount(() => { + renewSession() + }) const pageComponent = createMemo(() => { - const result = pagesMap[getPage().route] + const result = pagesMap[page().route] - // log.debug('page', getPage()) - - if (!result || getPage().path === '/404') { + if (!result || page().path === '/404') { return FourOuFourPage } @@ -89,10 +102,10 @@ export const Root = (props: PageProps) => { if (!isServer) { createEffect(() => { - const lang = new URLSearchParams(window.location.search).get('lang') || 'ru' + const lang = searchParams().lang || 'ru' console.log('[root] client locale is', lang) setLocale(lang) - }, [window.location.search]) + }) } return diff --git a/src/components/Topic/Card.tsx b/src/components/Topic/Card.tsx index 19684b27..31afbd10 100644 --- a/src/components/Topic/Card.tsx +++ b/src/components/Topic/Card.tsx @@ -9,7 +9,6 @@ import { locale } from '../../stores/ui' import { useAuthStore } from '../../stores/auth' import { follow, unfollow } from '../../stores/zine/common' import { getLogger } from '../../utils/logger' -import { Icon } from '../Nav/Icon' const log = getLogger('TopicCard') diff --git a/src/components/Views/AllAuthors.tsx b/src/components/Views/AllAuthors.tsx index 35592155..24752bff 100644 --- a/src/components/Views/AllAuthors.tsx +++ b/src/components/Views/AllAuthors.tsx @@ -1,14 +1,13 @@ -import { createEffect, createMemo, createSignal, For, Show } from 'solid-js' +import { createEffect, createMemo, For, Show } from 'solid-js' import type { Author } from '../../graphql/types.gen' import { AuthorCard } from '../Author/Card' import { Icon } from '../Nav/Icon' import { t } from '../../utils/intl' -import { useAuthorsStore, setSortAllBy as setSortAllAuthorsBy } from '../../stores/zine/authors' +import { useAuthorsStore, setAuthorsSort } from '../../stores/zine/authors' import { handleClientRouteLinkClick, useRouter } from '../../stores/router' import { useAuthStore } from '../../stores/auth' import { getLogger } from '../../utils/logger' import '../../styles/AllTopics.scss' -import { Topic } from '../../graphql/types.gen' const log = getLogger('AllAuthorsView') @@ -26,12 +25,12 @@ export const AllAuthorsView = (props: Props) => { const { session } = useAuthStore() createEffect(() => { - setSortAllAuthorsBy(getSearchParams().by || 'shouts') + setAuthorsSort(searchParams().by || 'shouts') }) const subscribed = (s) => Boolean(session()?.news?.authors && session()?.news?.authors?.includes(s || '')) - const { getSearchParams } = useRouter() + const { searchParams } = useRouter() const byLetter = createMemo<{ [letter: string]: Author[] }>(() => { return sortedAuthors().reduce((acc, author) => { @@ -73,17 +72,17 @@ export const AllAuthorsView = (props: Props) => {
    (
    diff --git a/src/components/Views/AllTopics.tsx b/src/components/Views/AllTopics.tsx index 15b40959..556866e9 100644 --- a/src/components/Views/AllTopics.tsx +++ b/src/components/Views/AllTopics.tsx @@ -2,7 +2,7 @@ import { createEffect, createMemo, For, Show } from 'solid-js' import type { Topic } from '../../graphql/types.gen' import { Icon } from '../Nav/Icon' import { t } from '../../utils/intl' -import { setSortAllBy as setSortAllTopicsBy, useTopicsStore } from '../../stores/zine/topics' +import { setTopicsSort, useTopicsStore } from '../../stores/zine/topics' import { handleClientRouteLinkClick, useRouter } from '../../stores/router' import { TopicCard } from '../Topic/Card' import { useAuthStore } from '../../stores/auth' @@ -20,17 +20,17 @@ type AllTopicsViewProps = { } export const AllTopicsView = (props: AllTopicsViewProps) => { - const { getSearchParams, changeSearchParam } = useRouter() + const { searchParams, changeSearchParam } = useRouter() const { sortedTopics } = useTopicsStore({ topics: props.topics, - sortBy: getSearchParams().by || 'shouts' + sortBy: searchParams().by || 'shouts' }) const { session } = useAuthStore() createEffect(() => { - setSortAllTopicsBy(getSearchParams().by || 'shouts') + setTopicsSort(searchParams().by || 'shouts') }) const byLetter = createMemo<{ [letter: string]: Topic[] }>(() => { @@ -69,17 +69,17 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
    (
    diff --git a/src/components/Views/Author.tsx b/src/components/Views/Author.tsx index 93bb3268..4c84b7c5 100644 --- a/src/components/Views/Author.tsx +++ b/src/components/Views/Author.tsx @@ -34,10 +34,10 @@ export const AuthorView = (props: AuthorProps) => { const { topicsByAuthor } = useTopicsStore() const author = createMemo(() => authorEntities()[props.authorSlug]) - const { getSearchParams, changeSearchParam } = useRouter() + const { searchParams, changeSearchParam } = useRouter() const title = createMemo(() => { - const m = getSearchParams().by + const m = searchParams().by if (m === 'viewed') return t('Top viewed') if (m === 'rating') return t('Top rated') if (m === 'commented') return t('Top discussed') @@ -51,7 +51,7 @@ export const AuthorView = (props: AuthorProps) => {