merged 'origin/auth' into prepare-editor

This commit is contained in:
tonyrewin 2022-10-22 15:38:01 +03:00
commit 335c69f64d
49 changed files with 1012 additions and 690 deletions

View File

@ -97,6 +97,7 @@
}
}
a[href*='vk.cc/'],
a[href*='vk.com/'] {
&::before {
background-image: url(/icons/vk-white.svg);

View File

@ -1,4 +1,4 @@
.discours-banner {
.discoursBanner {
background: #f8f8f8;
margin-bottom: 6.4rem;
padding: 0.8rem 0 0;
@ -7,10 +7,6 @@
font-size: 80%;
}
@include media-breakpoint-up(md) {
margin-top: -6.4rem;
}
h3 {
font-size: 3.2rem;
font-weight: 800;
@ -29,7 +25,7 @@
}
}
.discours-banner__content {
.discoursBannerContent {
display: flex;
flex-direction: column;
justify-content: center;
@ -47,7 +43,7 @@
}
}
.discours-banner__image {
.discoursBannerImage {
align-items: flex-end;
display: flex;
}

View File

@ -1,12 +1,13 @@
import './Banner.scss'
import styles from './Banner.module.scss'
import { t } from '../../utils/intl'
import { showModal } from '../../stores/ui'
import {clsx} from "clsx";
export default () => {
return (
<div class="discours-banner">
<div class={styles.discoursBanner}>
<div class="wide-container row">
<div class="discours-banner__content col-lg-5">
<div class={clsx(styles.discoursBannerContent, 'col-lg-5')}>
<h3>{t('Discours is created with our common effort')}</h3>
<p>
<a href="/about/help">{t('Support us')}</a>
@ -16,7 +17,7 @@ export default () => {
</a>
</p>
</div>
<div class="col-lg-6 offset-lg-1 discours-banner__image">
<div class={clsx(styles.discoursBannerImage, 'col-lg-6 offset-lg-1')}>
<img src="/discours-banner.jpg" alt={t('Discours')} />
</div>
</div>

View File

@ -1,362 +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<string>('')
const [mode, setMode] = createSignal<AuthMode>('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 (
<div class="row view" classList={{ 'view--sign-up': mode() === 'sign-up' }}>
<div class="col-sm-6 d-md-none auth-image">
<div class="auth-image__text" classList={{ show: mode() === 'sign-up' }}>
<h2>{t('Discours')}</h2>
<h4>{t(`Join the global community of authors!`)}</h4>
<p class="auth-benefits">
{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'
)}
.&nbsp;
{t('New stories every day and even more!')}
</p>
<p class="disclamer">
{t('By signing up you agree with our')}{' '}
<a
href="/about/terms-of-use"
onClick={(event) => {
hideModal()
handleClientRouteLinkClick(event)
}}
>
{t('terms of use')}
</a>
, {t('personal data usage and email notifications')}.
</p>
</div>
</div>
<div class="col-sm-6 auth">
<Form
initialValues={initial()}
validation={validation()}
onSubmit={async (form) => {
console.log('[auth] form values', form.values)
}}
>
<div class="auth__inner">
<h4>{titles[mode()]}</h4>
<div class={`auth-subtitle ${mode() === 'forget' ? '' : 'hidden'}`}>
<Show
when={mode() === 'forget'}
fallback={
<Show when={mode() === 'reset'}>
{t('Enter the code or click the link from email to confirm')}
</Show>
}
>
{t('Everything is ok, please give us your email address')}
</Show>
</div>
<Show when={authError()}>
<div class={`auth-info`}>
<ul>
<li class="warn">{authError()}</li>
</ul>
</div>
</Show>
{/*FIXME*/}
{/*<Show when={false && mode() === 'sign-up'}>*/}
{/* <div class='pretty-form__item'>*/}
{/* <input*/}
{/* id='username'*/}
{/* name='username'*/}
{/* autocomplete='username'*/}
{/* ref={usernameElement}*/}
{/* type='text'*/}
{/* placeholder={t('Username')}*/}
{/* />*/}
{/* <label for='username'>{t('Username')}</label>*/}
{/* </div>*/}
{/*</Show>*/}
<Show when={mode() !== 'reset' && mode() !== 'password'}>
<div class="pretty-form__item">
<input
id="email"
name="email"
autocomplete="email"
ref={emailElement}
type="text"
placeholder={t('Email')}
/>
<label for="email">{t('Email')}</label>
</div>
</Show>
<Show when={mode() === 'sign-up' || mode() === 'sign-in' || mode() === 'password'}>
<div class="pretty-form__item">
<input
id="password"
name="password"
autocomplete="current-password"
ref={passElement}
type="password"
placeholder={t('Password')}
/>
<label for="password">{t('Password')}</label>
</div>
</Show>
<Show when={mode() === 'reset'}>
<div class="pretty-form__item">
<input
id="resetcode"
name="resetcode"
ref={codeElement}
value={props.code}
type="text"
placeholder={t('Reset code')}
/>
<label for="resetcode">{t('Reset code')}</label>
</div>
</Show>
<Show when={mode() === 'password' || mode() === 'sign-up'}>
<div class="pretty-form__item">
<input
id="password2"
name="password2"
ref={pass2Element}
type="password"
placeholder={t('Password again')}
autocomplete=""
/>
<label for="password2">{t('Password again')}</label>
</div>
</Show>
<div>
<button class="button submitbtn" disabled={handshaking()} onClick={localAuth}>
{handshaking() ? '...' : titles[mode()]}
</button>
</div>
<Show when={mode() === 'sign-in'}>
<div class="auth-actions">
<a
href="#"
onClick={(ev) => {
ev.preventDefault()
changeMode('forget')
}}
>
{t('Forgot password?')}
</a>
</div>
</Show>
<Show when={mode() === 'sign-in' || mode() === 'sign-up'}>
<div class="social-provider">
<div class="providers-text">{t('Or continue with social network')}</div>
<div class="social">
<a href={''} class="facebook-auth" onClick={() => oauth('facebook')}>
<Icon name="facebook" />
</a>
<a href={''} class="google-auth" onClick={() => oauth('google')}>
<Icon name="google" />
</a>
<a href={''} class="vk-auth" onClick={() => oauth('vk')}>
<Icon name="vk" />
</a>
<a href={''} class="github-auth" onClick={() => oauth('github')}>
<Icon name="github" />
</a>
</div>
</div>
</Show>
<div class="auth-control">
<div classList={{ show: mode() === 'sign-up' }}>
<span class="auth-link" onClick={() => changeMode('sign-in')}>
{t('I have an account')}
</span>
</div>
<div classList={{ show: mode() === 'sign-in' }}>
<span class="auth-link" onClick={() => changeMode('sign-up')}>
{t('I have no account yet')}
</span>
</div>
<div classList={{ show: mode() === 'forget' }}>
<span class="auth-link" onClick={() => changeMode('sign-in')}>
{t('I know the password')}
</span>
</div>
<div classList={{ show: mode() === 'reset' }}>
<span class="auth-link" onClick={() => changeMode('resend')}>
{t('Resend code')}
</span>
</div>
</div>
</div>
</Form>
</div>
</div>
)
}

View File

@ -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,25 @@
.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;
}
}
}

View File

@ -0,0 +1,13 @@
.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;
}

View File

@ -0,0 +1,41 @@
import styles from './EmailConfirm.module.scss'
import authModalStyles from './AuthModal.module.scss'
import { clsx } from 'clsx'
import { t } from '../../../utils/intl'
import { hideModal } from '../../../stores/ui'
import { onMount } from 'solid-js'
import { useRouter } from '../../../stores/router'
import { confirmEmail } from '../../../stores/auth'
type ConfirmEmailSearchParams = {
token: string
}
export const EmailConfirm = () => {
const confirmedEmail = 'test@test.com'
const { searchParams } = useRouter<ConfirmEmailSearchParams>()
onMount(async () => {
const token = searchParams().token
try {
await confirmEmail(token)
} catch (error) {
console.log(error)
}
})
return (
<div>
<div class={styles.title}>{t('Hooray! Welcome!')}</div>
<div class={styles.text}>
{t("You've confirmed email")} {confirmedEmail}
</div>
<div>
<button class={clsx('button', authModalStyles.submitButton)} onClick={() => hideModal()}>
Перейти на главную
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,100 @@
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'
import { checkEmail, register } from '../../../stores/auth'
import { ApiError } from '../../../utils/apiClient'
type FormFields = {
email: string
}
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const ForgotPasswordForm = () => {
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
const handleEmailInput = (newEmail: string) => {
setValidationErrors(({ email: _notNeeded, ...rest }) => rest)
setEmail(newEmail)
}
const [submitError, setSubmitError] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
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 (
<form>
<h4>{t('Forgot password?')}</h4>
{t('Everything is ok, please give us your email address')}
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
</div>
</Show>
<div class="pretty-form__item">
<input
id="email"
name="email"
autocomplete="email"
type="email"
value={email()}
placeholder={t('Email')}
onInput={(event) => handleEmailInput(event.currentTarget.value)}
/>
<label for="email">{t('Email')}</label>
</div>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Restore password')}
</button>
</div>
<div class={styles.authControl}>
<span class={styles.authLink} onClick={() => changeSearchParam('mode', 'login')}>
{t('I know the password')}
</span>
</div>
</form>
)
}

View File

@ -0,0 +1,150 @@
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 } 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'
type FormFields = {
email: string
password: string
}
type ValidationErrors = Partial<Record<keyof FormFields, string>>
export const LoginForm = () => {
const [submitError, setSubmitError] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
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 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')
}
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() })
} catch (error) {
if (error instanceof ApiError) {
if (error.code === 'email_not_confirmed') {
setSubmitError(t('Please, confirm email'))
return
}
if (error.code === 'user_not_found') {
setSubmitError(t('Something went wrong, check email and password'))
return
}
}
setSubmitError(error.message)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<h4>{t('Enter the Discours')}</h4>
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
</div>
</Show>
<div class="pretty-form__item">
<input
id="email"
name="email"
autocomplete="email"
type="email"
value={email()}
placeholder={t('Email')}
onInput={(event) => handleEmailInput(event.currentTarget.value)}
/>
<label for="email">{t('Email')}</label>
</div>
<Show when={validationErrors().email}>
<div class={styles.validationError}>{validationErrors().email}</div>
</Show>
<div class="pretty-form__item">
<input
id="password"
name="password"
autocomplete="password"
type="password"
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
</div>
<Show when={validationErrors().password}>
<div class={styles.validationError}>{validationErrors().password}</div>
</Show>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Enter')}
</button>
</div>
<div class={styles.authActions}>
<a
href="#"
onClick={(ev) => {
ev.preventDefault()
changeSearchParam('mode', 'forgot-password')
}}
>
{t('Forgot password?')}
</a>
</div>
<SocialProviders />
<div class={styles.authControl}>
<span class={styles.authLink} onClick={() => changeSearchParam('mode', 'register')}>
{t('I have no account yet')}
</span>
</div>
</form>
)
}

View File

@ -0,0 +1,189 @@
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'
type FormFields = {
name: string
email: string
password: string
}
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const RegisterForm = () => {
const { changeSearchParam } = useRouter<AuthModalSearchParams>()
const { emailChecks } = useAuthStore()
const [submitError, setSubmitError] = createSignal('')
const [name, setName] = createSignal('')
const [password, setPassword] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
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()
})
} catch (error) {
if (error instanceof ApiError && error.code === 'user_already_exists') {
return
}
setSubmitError(error.message)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit}>
<h4>{t('Create account')}</h4>
<Show when={submitError()}>
<div class={styles.authInfo}>
<ul>
<li class={styles.warn}>{submitError()}</li>
</ul>
</div>
</Show>
<div class="pretty-form__item">
<input
id="name"
name="name"
type="text"
placeholder={t('Full name')}
autocomplete=""
onInput={(event) => handleNameInput(event.currentTarget.value)}
/>
<label for="name">{t('Full name')}</label>
</div>
<Show when={validationErrors().name}>
<div class={styles.validationError}>{validationErrors().name}</div>
</Show>
<div class="pretty-form__item">
<input
id="email"
name="email"
autocomplete="email"
type="text"
value={email()}
placeholder={t('Email')}
onInput={(event) => handleEmailInput(event.currentTarget.value)}
onBlur={handleEmailBlur}
/>
<label for="email">{t('Email')}</label>
</div>
<Show when={validationErrors().email}>
<div class={styles.validationError}>{validationErrors().email}</div>
</Show>
<Show when={emailChecks()[email()]}>
<div class={styles.validationError}>
{t("This email is already taken. If it's you")},{' '}
<a
href="#"
onClick={(event) => {
event.preventDefault()
changeSearchParam('mode', 'login')
}}
>
{t('enter')}
</a>
</div>
</Show>
<div class="pretty-form__item">
<input
id="password"
name="password"
autocomplete="current-password"
type="password"
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
</div>
<Show when={validationErrors().password}>
<div class={styles.validationError}>{validationErrors().password}</div>
</Show>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Join')}
</button>
</div>
<SocialProviders />
<div class={styles.authControl}>
<span class={styles.authLink} onClick={() => changeSearchParam('mode', 'login')}>
{t('I have an account')}
</span>
</div>
</form>
)
}

View File

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

View File

@ -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 (
<div class={styles.container}>
<div class={styles.text}>{t('Or continue with social network')}</div>
<div class={styles.social}>
<a href="#" onClick={(event) => handleSocialAuthLinkClick(event, 'facebook')}>
<Icon name="facebook" />
</a>
<a href="#" onClick={(event) => handleSocialAuthLinkClick(event, 'google')}>
<Icon name="google" />
</a>
<a href="#" onClick={(event) => handleSocialAuthLinkClick(event, 'vk')}>
<Icon name="vk" />
</a>
<a
href="#"
class={styles.githubAuth}
onClick={(event) => handleSocialAuthLinkClick(event, 'github')}
>
<Icon name="github" />
</a>
</div>
</div>
)
}

View File

@ -0,0 +1,88 @@
import { Show } from 'solid-js/web'
import { createEffect, createMemo, onMount } 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<AuthModalMode, AuthModalMode> = {
login: 'login',
register: 'register',
'forgot-password': 'forgot-password',
// eslint-disable-next-line sonarjs/no-duplicate-string
'confirm-email': 'confirm-email'
}
export const AuthModal = () => {
let rootRef: HTMLDivElement
const { searchParams } = useRouter<AuthModalSearchParams>()
const mode = createMemo<AuthModalMode>(() => {
return AUTH_MODAL_MODES[searchParams().mode] || 'login'
})
createEffect((oldMode) => {
if (oldMode !== mode()) {
rootRef?.querySelector('input')?.focus()
}
}, null)
return (
<div
ref={rootRef}
class={clsx('row', styles.view)}
classList={{ [styles.signUp]: mode() === 'register' || mode() === 'confirm-email' }}
>
<div class={clsx('col-sm-6', 'd-md-none', styles.authImage)}>
<div
class={styles.authImageText}
classList={{ [styles.hidden]: mode() !== 'register' && mode() !== 'confirm-email' }}
>
<h2>{t('Discours')}</h2>
<h4>{t(`Join the global community of authors!`)}</h4>
<p class={styles.authBenefits}>
{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'
)}
.&nbsp;
{t('New stories every day and even more!')}
</p>
<p class={styles.disclaimer}>
{t('By signing up you agree with our')}{' '}
<a
href="/about/terms-of-use"
onClick={(event) => {
hideModal()
handleClientRouteLinkClick(event)
}}
>
{t('terms of use')}
</a>
, {t('personal data usage and email notifications')}.
</p>
</div>
</div>
<div class={clsx('col-sm-6', styles.auth)}>
<Show when={mode() === 'login'}>
<LoginForm />
</Show>
<Show when={mode() === 'register'}>
<RegisterForm />
</Show>
<Show when={mode() === 'forgot-password'}>
<ForgotPasswordForm />
</Show>
<Show when={mode() === 'confirm-email'}>
<EmailConfirm />
</Show>
</div>
</div>
)
}

View File

@ -0,0 +1,5 @@
import { createSignal } from 'solid-js'
const [email, setEmail] = createSignal('')
export { email, setEmail }

View File

@ -0,0 +1,5 @@
export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password'
export type AuthModalSearchParams = {
mode: AuthModalMode
}

View File

@ -0,0 +1,7 @@
export const isValidEmail = (email: string) => {
if (!email) {
return false
}
return email.includes('@') && email.includes('.') && email.length > 5
}

View File

@ -4,9 +4,9 @@ import Notifications from './Notifications'
import { Icon } from './Icon'
import { Modal } from './Modal'
import { Popup } from './Popup'
import AuthModal from './AuthModal'
import { AuthModal } from './AuthModal'
import { t } from '../../utils/intl'
import {useModalStore, showModal, useWarningsStore, toggleModal} from '../../stores/ui'
import { useModalStore, showModal, useWarningsStore } from '../../stores/ui'
import { useAuthStore } from '../../stores/auth'
import { handleClientRouteLinkClick, router, Routes, useRouter } from '../../stores/router'
import styles from './Header.module.scss'
@ -21,10 +21,6 @@ const resources: { name: string; route: keyof Routes }[] = [
{ name: t('topics'), route: 'topics' }
]
const handleEnterClick = () => {
showModal('auth')
}
type Props = {
title?: string
isHeaderFixed?: boolean
@ -37,27 +33,29 @@ export const Header = (props: Props) => {
const [fixed, setFixed] = createSignal(false)
const [visibleWarnings, setVisibleWarnings] = 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(!fixed())
// effects
createEffect(() => {
const isFixed = fixed() || (getModal() && getModal() !== 'share');
const isFixed = fixed() || (modal() && modal() !== 'share');
document.body.classList.toggle('fixed', isFixed);
document.body.classList.toggle(styles.fixed, isFixed && !getModal());
}, [fixed(), getModal()])
document.body.classList.toggle(styles.fixed, isFixed && !modal());
})
// derived
const authorized = createMemo(() => session()?.user?.slug)
const handleBellIconClick = () => {
const handleBellIconClick = (event: Event) => {
event.preventDefault()
if (!authorized()) {
showModal('auth')
return
@ -100,31 +98,31 @@ export const Header = (props: Props) => {
<ul class="nodash">
<li>
<a href="#">
<Icon name="vk-white" class={stylesPopup.icon}/>
<Icon name="vk-white" class={stylesPopup.icon} />
VK
</a>
</li>
<li>
<a href="#">
<Icon name="facebook-white" class={stylesPopup.icon}/>
<Icon name="facebook-white" class={stylesPopup.icon} />
Facebook
</a>
</li>
<li>
<a href="#">
<Icon name="twitter-white" class={stylesPopup.icon}/>
<Icon name="twitter-white" class={stylesPopup.icon} />
Twitter
</a>
</li>
<li>
<a href="#">
<Icon name="telegram-white" class={stylesPopup.icon}/>
<Icon name="telegram-white" class={stylesPopup.icon} />
Telegram
</a>
</li>
<li>
<a href="#">
<Icon name="link-white" class={stylesPopup.icon}/>
<Icon name="link-white" class={stylesPopup.icon} />
{t('Copy link')}
</a>
</li>
@ -148,7 +146,7 @@ export const Header = (props: Props) => {
>
<For each={resources}>
{(r) => (
<li classList={{ [styles.selected]: r.route === getPage().route }}>
<li classList={{ [styles.selected]: r.route === page().route }}>
<a href={getPagePath(router, r.route, null)} onClick={handleClientRouteLinkClick}>
{r.name}
</a>
@ -166,9 +164,9 @@ export const Header = (props: Props) => {
<div class={styles.usernav}>
<div class={clsx(privateStyles.userControl, styles.userControl, 'col')}>
<div class={privateStyles.userControlItem}>
<a href="#auth" onClick={handleBellIconClick}>
<a href="#" onClick={handleBellIconClick}>
<div>
<Icon name="bell-white" counter={authorized() ? getWarnings().length : 1} />
<Icon name="bell-white" counter={authorized() ? warnings().length : 1} />
</div>
</a>
</div>
@ -183,7 +181,7 @@ export const Header = (props: Props) => {
when={authorized()}
fallback={
<div class={clsx(privateStyles.userControlItem, 'loginbtn')}>
<a href="#auth" onClick={handleEnterClick}>
<a href="?modal=auth&mode=login" onClick={handleClientRouteLinkClick}>
<Icon name="user-anonymous" />
</a>
</div>
@ -194,8 +192,13 @@ export const Header = (props: Props) => {
</div>
<Show when={props.title}>
<div class={styles.articleControls}>
<button onClick={() => {toggleModal('share')}}>
<Icon name="share-outline" class={styles.icon}/>
<button
onClick={() => {
// FIXME: Popup
showModal('share')
}}
>
<Icon name="share-outline" class={styles.icon} />
</button>
<a href="#comments">
<Icon name="comments-outline" class={styles.icon} />

View File

@ -1,4 +1,5 @@
import { createEffect, createSignal, JSX, onMount, Show } from 'solid-js'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import type { JSX } from 'solid-js'
import './Modal.scss'
import { hideModal, useModalStore } from '../../stores/ui'
@ -7,23 +8,30 @@ interface ModalProps {
children: JSX.Element
}
export const Modal = (props: ModalProps) => {
const { getModal } = useModalStore()
const keydownHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') hideModal()
}
const wrapClick = (ev: Event) => {
if ((ev.target as HTMLElement).classList.contains('modalwrap')) hideModal()
export const Modal = (props: ModalProps) => {
const { modal } = useModalStore()
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)
console.debug(`[modal] ${props.name} is ${getModal() === props.name ? 'visible' : 'hidden'}`)
setVisible(modal() === props.name)
console.debug(`[auth.modal] ${props.name} is ${modal() === props.name ? 'visible' : 'hidden'}`)
})
return (

View File

@ -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 (
<Show when={notSeen().length > 0}>
<Portal>
<ul class="warns">
<For each={getWarnings()}>{(warning) => <li>{warning.body}</li>}</For>
<For each={warnings()}>{(warning) => <li>{warning.body}</li>}</For>
</ul>
</Portal>
</Show>

View File

@ -1,7 +1,7 @@
import { createEffect, createSignal, onMount, Show } from 'solid-js'
import style from './Popup.module.scss'
import { hideModal, useModalStore } from '../../stores/ui'
import {clsx} from 'clsx';
import { clsx } from 'clsx'
interface PopupProps {
name: string
@ -10,7 +10,7 @@ interface PopupProps {
}
export const Popup = (props: PopupProps) => {
const { getModal } = useModalStore()
const { modal } = useModalStore()
onMount(() => {
window.addEventListener('keydown', (e: KeyboardEvent) => {
@ -20,14 +20,12 @@ export const Popup = (props: PopupProps) => {
const [visible, setVisible] = createSignal(false)
createEffect(() => {
setVisible(getModal() === props.name)
setVisible(modal() === props.name)
})
return (
<Show when={visible()}>
<div class={clsx(style.popup, props.class)}>
{props.children}
</div>
<div class={clsx(style.popup, props.class)}>{props.children}</div>
</Show>
)
}

View File

@ -8,7 +8,7 @@ import { clsx } from 'clsx'
export default () => {
const { session } = useAuthStore()
const { getPage } = useRouter()
const { page } = useRouter()
return (
<div class={clsx(styles.userControl, 'col')}>
@ -21,7 +21,7 @@ export default () => {
<div class={clsx(styles.userControlItem, styles.userControlItemInbox)}>
<a href="/inbox">
{/*FIXME: replace with route*/}
<div classList={{ entered: getPage().path === '/inbox' }}>
<div classList={{ entered: page().path === '/inbox' }}>
<Icon name="inbox-white" counter={session()?.news?.unread || 0} />
</div>
</a>
@ -29,7 +29,7 @@ export default () => {
<div class={styles.userControlItem}>
<a href={`/${session().user?.slug}`}>
{/*FIXME: replace with route*/}
<div classList={{ entered: getPage().path === `/${session().user?.slug}` }}>
<div classList={{ entered: page().path === `/${session().user?.slug}` }}>
<Userpic user={session().user as Author} />
</div>
</a>

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
// FIXME: breaks on vercel, research
// import 'solid-devtools'
import { setLocale } from '../stores/ui'
import { hideModal, MODALS, setLocale, showModal } from '../stores/ui'
import { Component, createEffect, createMemo } from 'solid-js'
import { Routes, useRouter } from '../stores/router'
import { Dynamic, isServer } from 'solid-js/web'
@ -47,6 +47,12 @@ import { CreatePage } from './Pages/CreatePage'
// const ThanksPage = lazy(() => import('./Pages/about/ThanksPage'))
// const CreatePage = lazy(() => import('./Pages/about/CreatePage'))
type RootSearchParams = {
modal: string
lang: string
}
const pagesMap: Record<keyof Routes, Component<PageProps>> = {
create: CreatePage,
home: HomePage,
@ -68,16 +74,19 @@ const pagesMap: Record<keyof Routes, Component<PageProps>> = {
}
export const Root = (props: PageProps) => {
const { getPage } = useRouter()
const { page, searchParams } = useRouter<RootSearchParams>()
// log.debug({ route: getPage().route })
createEffect(() => {
const modal = MODALS[searchParams().modal]
if (modal) {
showModal(modal)
}
})
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
}
@ -86,10 +95,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 <Dynamic component={pageComponent()} {...props} />

View File

@ -3,7 +3,7 @@ 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 '../../styles/AllTopics.scss'
@ -22,12 +22,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<AllAuthorsPageSearchParams>()
const { searchParams } = useRouter<AllAuthorsPageSearchParams>()
const byLetter = createMemo<{ [letter: string]: Author[] }>(() => {
return sortedAuthors().reduce((acc, author) => {
@ -69,17 +69,17 @@ export const AllAuthorsView = (props: Props) => {
<div class="row">
<div class="col">
<ul class="view-switcher">
<li classList={{ selected: getSearchParams().by === 'shouts' }}>
<li classList={{ selected: searchParams().by === 'shouts' }}>
<a href="/authors?by=shouts" onClick={handleClientRouteLinkClick}>
{t('By shouts')}
</a>
</li>
<li classList={{ selected: getSearchParams().by === 'rating' }}>
<li classList={{ selected: searchParams().by === 'rating' }}>
<a href="/authors?by=rating" onClick={handleClientRouteLinkClick}>
{t('By rating')}
</a>
</li>
<li classList={{ selected: !getSearchParams().by || getSearchParams().by === 'name' }}>
<li classList={{ selected: !searchParams().by || searchParams().by === 'name' }}>
<a href="/authors" onClick={handleClientRouteLinkClick}>
{t('By alphabet')}
</a>
@ -92,7 +92,7 @@ export const AllAuthorsView = (props: Props) => {
</li>
</ul>
<Show
when={!getSearchParams().by || getSearchParams().by === 'name'}
when={!searchParams().by || searchParams().by === 'name'}
fallback={() => (
<div class="stats">
<For each={sortedAuthors()}>

View File

@ -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'
@ -17,17 +17,17 @@ type AllTopicsViewProps = {
}
export const AllTopicsView = (props: AllTopicsViewProps) => {
const { getSearchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
const { searchParams, changeSearchParam } = useRouter<AllTopicsPageSearchParams>()
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[] }>(() => {
@ -66,17 +66,17 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
<div class="row">
<div class="col">
<ul class="view-switcher">
<li classList={{ selected: getSearchParams().by === 'shouts' || !getSearchParams().by }}>
<li classList={{ selected: searchParams().by === 'shouts' || !searchParams().by }}>
<a href="/topics?by=shouts" onClick={handleClientRouteLinkClick}>
{t('By shouts')}
</a>
</li>
<li classList={{ selected: getSearchParams().by === 'authors' }}>
<li classList={{ selected: searchParams().by === 'authors' }}>
<a href="/topics?by=authors" onClick={handleClientRouteLinkClick}>
{t('By authors')}
</a>
</li>
<li classList={{ selected: getSearchParams().by === 'title' }}>
<li classList={{ selected: searchParams().by === 'title' }}>
<a
href="/topics?by=title"
onClick={(ev) => {
@ -97,7 +97,7 @@ export const AllTopicsView = (props: AllTopicsViewProps) => {
</ul>
<Show
when={getSearchParams().by === 'title'}
when={searchParams().by === 'title'}
fallback={() => (
<div class="stats">
<For each={sortedTopics()}>

View File

@ -34,10 +34,10 @@ export const AuthorView = (props: AuthorProps) => {
const { topicsByAuthor } = useTopicsStore()
const author = createMemo(() => authorEntities()[props.authorSlug])
const { getSearchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
const { searchParams, changeSearchParam } = useRouter<AuthorPageSearchParams>()
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) => {
<div class="row group__controls">
<div class="col-md-8">
<ul class="view-switcher">
<li classList={{ selected: !getSearchParams().by || getSearchParams().by === 'recent' }}>
<li classList={{ selected: !searchParams().by || searchParams().by === 'recent' }}>
<button type="button" onClick={() => changeSearchParam('by', 'recent')}>
{t('Recent')}
</button>

View File

@ -19,7 +19,7 @@ export const SearchView = (props: Props) => {
const { sortedArticles } = useArticlesStore({ sortedArticles: props.results })
const [getQuery, setQuery] = createSignal(props.query)
const { getSearchParams } = useRouter<SearchPageSearchParams>()
const { searchParams } = useRouter<SearchPageSearchParams>()
const handleQueryChange = (ev) => {
setQuery(ev.target.value)
@ -48,7 +48,7 @@ export const SearchView = (props: Props) => {
<ul class="view-switcher">
<li
classList={{
selected: getSearchParams().by === 'relevance'
selected: searchParams().by === 'relevance'
}}
>
<a href="?by=relevance" onClick={handleClientRouteLinkClick}>
@ -57,7 +57,7 @@ export const SearchView = (props: Props) => {
</li>
<li
classList={{
selected: getSearchParams().by === 'rating'
selected: searchParams().by === 'rating'
}}
>
<a href="?by=rating" onClick={handleClientRouteLinkClick}>

View File

@ -23,7 +23,7 @@ interface TopicProps {
}
export const TopicView = (props: TopicProps) => {
const { getSearchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>()
const { searchParams, changeSearchParam } = useRouter<TopicsPageSearchParams>()
const { sortedArticles } = useArticlesStore({ sortedArticles: props.topicArticles })
const { topicEntities } = useTopicsStore({ topics: [props.topic] })
@ -33,7 +33,7 @@ export const TopicView = (props: TopicProps) => {
const topic = createMemo(() => topicEntities()[props.topicSlug])
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')
@ -47,7 +47,7 @@ export const TopicView = (props: TopicProps) => {
<div class="row group__controls">
<div class="col-md-8">
<ul class="view-switcher">
<li classList={{ selected: getSearchParams().by === 'recent' || !getSearchParams().by }}>
<li classList={{ selected: searchParams().by === 'recent' || !searchParams().by }}>
<button type="button" onClick={() => changeSearchParam('by', 'recent')}>
{t('Recent')}
</button>

View File

@ -1,9 +1,26 @@
import { gql } from '@urql/core'
export default gql`
query ConfirmEmailQuery($code: String!) {
mutation ConfirmEmailMutation($code: String!) {
confirmEmail(code: $code) {
error
token
user {
_id: slug
email
name
slug
bio
userpic
links
}
news {
unread
topics
authors
reactions
communities
}
}
}
`

View File

@ -1,18 +1,9 @@
import { gql } from '@urql/core'
export default gql`
mutation RegisterMutation($email: String!, $password: String, $name: String) {
mutation RegisterMutation($email: String!, $password: String!, $name: String!) {
registerUser(email: $email, password: $password, name: $name) {
error
token
user {
_id: slug
name
slug
userpic
bio
# links
}
}
}
`

View File

@ -15,9 +15,9 @@ export default gql`
}
news {
unread
inbox
topics
authors
reactions
communities
}
}

View File

@ -1,7 +1,6 @@
import { createClient, ClientOptions, dedupExchange, fetchExchange, Exchange } from '@urql/core'
import { devtoolsExchange } from '@urql/devtools'
import { baseUrl } from './publicGraphQLClient'
import { isDev } from '../utils/config'
import { isDev, apiBaseUrl } from '../utils/config'
const TOKEN_LOCAL_STORAGE_KEY = 'token'
@ -20,7 +19,7 @@ export const resetToken = () => {
}
const options: ClientOptions = {
url: baseUrl,
url: apiBaseUrl,
maskTypename: true,
requestPolicy: 'cache-and-network',
fetchOptions: () => {

View File

@ -1,9 +1,6 @@
import { ClientOptions, dedupExchange, fetchExchange, createClient, Exchange } from '@urql/core'
import { devtoolsExchange } from '@urql/devtools'
import { isDev } from '../utils/config'
export const baseUrl = 'https://newapi.discours.io'
// export const baseUrl = 'http://localhost:8000'
import { isDev, apiBaseUrl } from '../utils/config'
const exchanges: Exchange[] = [dedupExchange, fetchExchange]
@ -12,7 +9,7 @@ if (isDev) {
}
const options: ClientOptions = {
url: baseUrl,
url: apiBaseUrl,
maskTypename: true,
requestPolicy: 'cache-and-network',
exchanges

View File

@ -260,8 +260,8 @@ export type MutationRateUserArgs = {
export type MutationRegisterUserArgs = {
email: Scalars['String']
password?: InputMaybe<Scalars['String']>
name?: InputMaybe<Scalars['String']>
password?: InputMaybe<Scalars['String']>
}
export type MutationRemoveAuthorArgs = {
@ -271,6 +271,7 @@ export type MutationRemoveAuthorArgs = {
export type MutationSendLinkArgs = {
email: Scalars['String']
lang?: InputMaybe<Scalars['String']>
}
export type MutationUnfollowArgs = {
@ -489,6 +490,7 @@ export type QueryShoutsForFeedArgs = {
export type QuerySignInArgs = {
email: Scalars['String']
lang?: InputMaybe<Scalars['String']>
password?: InputMaybe<Scalars['String']>
}

View File

@ -132,7 +132,7 @@
"collections": "коллекции",
"community": "сообщество",
"email not confirmed": "email не подтвержден",
"enter": ойти",
"Enter": "Войти",
"feed": "лента",
"follower": "подписчик",
"invalid password": "некорректный пароль",
@ -147,22 +147,16 @@
"Please, confirm email": "Пожалуйста, подтвердите электронную почту",
"Something went wrong, check email and password": "Что-то пошло не так. Проверьте адрес электронной почты и пароль",
"You was successfully authorized": "Вы были успешно авторизованы",
"Help discours to grow": "Поддержка дискурса",
"One time": "Единоразово",
"Every month": "Ежемесячно",
"Another amount": "Другая сумма",
"Just start typing...": "Просто начните...",
"Tips and proposals": "Советы и предложения",
"Invite coauthors": "Пригласить соавторов",
"Tabula rasa": "С чистого листа",
"Publication settings": "Настройки публикации",
"History of changes": "История правок",
"Undo": "Откат",
"Redo": "Повторить действие",
"Stop collab": "Индивидуальный режим",
"Restart collab": "Перезапустить коллаборацию",
"Start collab": "Коллаборативный режим",
"Clear": "Сбросить",
"Theme": "Режим",
"Editing conflict, please copy your version and refresh page": "Конфликт редактирования, пожалуйста, скопируйте вашу версию текста и обновите страницу"
"Invalid email": "Проверьте правильность ввода почты",
"Please enter email": "Пожалуйста, введите почту",
"Please enter password": "Пожалуйста, введите пароль",
"Please enter password again": "Пожалуйста, введите пароль ещё рез",
"Join": "Присоединиться",
"Please enter a name to sign your comments and publication": "Пожалуйста, введите имя, которое будет отображаться на сайте",
"Full name": "Имя и фамилия",
"Restore password": "Восстановить пароль",
"Hooray! Welcome!": "Ура! Добро пожаловать!",
"You've confirmed email": "Вы подтвердили почту",
"This email is already taken. If it's you": "Такой email уже зарегистрирован. Если это вы",
"enter": "войдите"
}

View File

@ -1,31 +0,0 @@
import Mailgun from 'mailgun.js'
import FormDataPackage from 'form-data'
const domain: string = process.env.MAILGUN_DOMAIN
const key: string = process.env.MAILGUN_API_KEY
const mailgun = new Mailgun(FormDataPackage)
const mg = mailgun.client({
username: 'api',
key,
url: 'https://api.mailgun.net'
})
export type EmailContent = {
to: string | string[]
subject: string
text: string
html?: string
from?: string
}
const from = `discours.io <noreply@discours.io>`
export default async function handler(req, res) {
const { to, subject } = req.query
const token = '' // FIXME
const text = 'Follow the link to confirm email: https://new.discours.io/confirm/' + token // TODO: use templates here
mg.messages
.create(domain, { to, subject, text, from } as EmailContent)
.then((_) => res.status(200))
.catch(console.error)
}

3
src/pages/welcome.astro Normal file
View File

@ -0,0 +1,3 @@
---
return Astro.redirect('/?modal=auth&mode=welcome')
---

View File

@ -1,4 +1,3 @@
import { atom } from 'nanostores'
import type { AuthResult } from '../graphql/types.gen'
import { resetToken, setToken } from '../graphql/privateGraphQLClient'
import { apiClient } from '../utils/apiClient'
@ -13,37 +12,46 @@ export const signIn = async (params) => {
console.debug('signed in')
}
export const signUp = async (params) => {
const authResult = await apiClient.authRegister(params)
setSession(authResult)
setToken(authResult.token)
console.debug('signed up')
}
export const signOut = () => {
// TODO: call backend to revoke token
setSession(null)
resetToken()
console.debug('signed out')
}
export const emailChecks = atom<{ [email: string]: boolean }>({})
export const [emailChecks, setEmailChecks] = createSignal<{ [email: string]: boolean }>({})
export const signCheck = async (params) => {
emailChecks.set(await apiClient.authCheckEmail(params))
export const checkEmail = async (email: string): Promise<boolean> => {
if (emailChecks()[email]) {
return true
}
const checkResult = await apiClient.authCheckEmail({ email })
if (checkResult) {
setEmailChecks((oldEmailChecks) => ({ ...oldEmailChecks, [email]: true }))
return true
}
return false
}
export const resetCode = atom<string>()
export const [resetCode, setResetCode] = createSignal('')
export const register = async ({ email, password }: { email: string; password: string }) => {
const authResult = await apiClient.authRegister({
export const register = async ({
name,
email,
password
}: {
name: string
email: string
password: string
}) => {
await apiClient.authRegister({
name,
email,
password
})
if (authResult && !authResult.error) {
console.debug('register session update', authResult)
setSession(authResult)
}
}
export const signSendLink = async (params) => {
@ -51,18 +59,24 @@ export const signSendLink = async (params) => {
resetToken()
}
export const signConfirm = async (params) => {
const auth = await apiClient.authConfirmCode(params) // { code }
setToken(auth.token)
setSession(auth)
}
export const renewSession = async () => {
const authResult = await apiClient.getSession() // token in header
setToken(authResult.token)
setSession(authResult)
}
export const useAuthStore = () => {
return { session }
export const confirmEmail = async (token: string) => {
const authResult = await apiClient.confirmEmail({ token })
setToken(authResult.token)
setSession(authResult)
}
export const confirmEmail = async (token: string) => {
const authResult = await apiClient.confirmEmail({ token })
setToken(authResult.token)
setSession(authResult)
}
export const useAuthStore = () => {
return { session, emailChecks }
}

View File

@ -69,8 +69,11 @@ export const handleClientRouteLinkClick = (event) => {
const url = new URL(link.href)
if (url.origin === location.origin) {
event.preventDefault()
// TODO: search params
routerStore.open(url.pathname)
const params = Object.fromEntries(new URLSearchParams(url.search))
searchParamsStore.open(params)
window.scrollTo({
top: 0,
left: 0
@ -91,16 +94,27 @@ if (!isServer) {
}
export const useRouter = <TSearchParams extends Record<string, string> = Record<string, string>>() => {
const getPage = useStore(routerStore)
const getSearchParams = useStore(searchParamsStore) as unknown as Accessor<TSearchParams>
const page = useStore(routerStore)
const searchParams = useStore(searchParamsStore) as unknown as Accessor<TSearchParams>
const changeSearchParam = <TKey extends keyof TSearchParams>(key: TKey, value: TSearchParams[TKey]) => {
searchParamsStore.open({ ...searchParamsStore.get(), [key]: value })
const changeSearchParam = <TKey extends keyof TSearchParams>(
key: TKey,
value: TSearchParams[TKey],
replace = false
) => {
const newSearchParams = { ...searchParamsStore.get() }
if (value === null) {
delete newSearchParams[key.toString()]
} else {
newSearchParams[key.toString()] = value
}
searchParamsStore.open(newSearchParams, replace)
}
return {
getPage,
getSearchParams,
page,
searchParams,
changeSearchParam
}
}

View File

@ -1,11 +1,10 @@
//import { persistentAtom } from '@nanostores/persistent'
import { atom } from 'nanostores'
import { useStore } from '@nanostores/solid'
import { createSignal } from 'solid-js'
import { useRouter } from './router'
//export const locale = persistentAtom<string>('locale', 'ru')
export const [locale, setLocale] = createSignal('ru')
export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'share' | 'thank' | 'donate' | null
export type ModalType = 'auth' | 'subscribe' | 'feedback' | 'share' | 'thank' | 'donate'
type WarnKind = 'error' | 'warn' | 'info'
export interface Warning {
@ -14,28 +13,38 @@ export interface Warning {
seen?: boolean
}
const modal = atom<ModalType>(null)
export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth',
subscribe: 'subscribe',
feedback: 'feedback',
share: 'share',
thank: 'thank',
donate: 'donate'
}
const warnings = atom<Warning[]>([])
const [modal, setModal] = createSignal<ModalType | null>(null)
export const showModal = (modalType: ModalType) => modal.set(modalType)
export const hideModal = () => modal.set(null)
export const toggleModal = (modalType) => modal.get() ? hideModal() : showModal(modalType)
const [warnings, setWarnings] = createSignal<Warning[]>([])
export const clearWarns = () => warnings.set([])
export const warn = (warning: Warning) => warnings.set([...warnings.get(), warning])
export const showModal = (modalType: ModalType) => setModal(modalType)
export const hideModal = () => {
const { changeSearchParam } = useRouter()
changeSearchParam('modal', null, true)
changeSearchParam('mode', null, true)
setModal(null)
}
export const clearWarns = () => setWarnings([])
export const warn = (warning: Warning) => setWarnings([...warnings(), warning])
export const useWarningsStore = () => {
const getWarnings = useStore(warnings)
return {
getWarnings
warnings
}
}
export const useModalStore = () => {
const getModal = useStore(modal)
return {
getModal
modal
}
}

View File

@ -7,7 +7,7 @@ export type AuthorsSortBy = 'shouts' | 'name' | 'rating'
const [sortAllBy, setSortAllBy] = createSignal<AuthorsSortBy>('shouts')
export { setSortAllBy }
export const setAuthorsSort = (sortBy: AuthorsSortBy) => setSortAllBy(sortBy)
const [authorEntities, setAuthorEntities] = createSignal<{ [authorSlug: string]: Author }>({})
const [authorsByTopic, setAuthorsByTopic] = createSignal<{ [topicSlug: string]: Author[] }>({})

View File

@ -8,7 +8,7 @@ export type TopicsSortBy = 'followers' | 'title' | 'authors' | 'shouts'
const [sortAllBy, setSortAllBy] = createSignal<TopicsSortBy>('shouts')
export { setSortAllBy }
export const setTopicsSort = (sortBy: TopicsSortBy) => setSortAllBy(sortBy)
const [topicEntities, setTopicEntities] = createSignal<{ [topicSlug: string]: Topic }>({})
const [randomTopics, setRandomTopics] = createSignal<Topic[]>([])

View File

@ -13,7 +13,7 @@
--danger-color: #fc6847;
--lightgray-color: rgb(84 16 17 / 6%);
--font: -apple-system, blinkmacsystemfont, 'Segoe UI', roboto, oxygen, ubuntu, cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
'Helvetica Neue', sans-serif;
}
* {
@ -277,6 +277,7 @@ form {
select,
textarea {
&:focus,
&:-webkit-autofill,
&:not(:placeholder-shown) {
& ~ label {
font-size: 60%;

View File

@ -11,7 +11,7 @@ import authLogoutQuery from '../graphql/mutation/auth-logout'
import authLoginQuery from '../graphql/query/auth-login'
import authRegisterMutation from '../graphql/mutation/auth-register'
import authCheckEmailQuery from '../graphql/query/auth-check-email'
import authConfirmCodeMutation from '../graphql/mutation/auth-confirm-email'
import authConfirmEmailMutation from '../graphql/mutation/auth-confirm-email'
import authSendLinkMutation from '../graphql/mutation/auth-send-link'
import followMutation from '../graphql/mutation/follow'
import unfollowMutation from '../graphql/mutation/unfollow'
@ -32,7 +32,7 @@ import myChats from '../graphql/query/my-chats'
const FEED_SIZE = 50
const REACTIONS_PAGE_SIZE = 100
type ApiErrorCode = 'unknown' | 'email_not_confirmed' | 'user_not_found'
type ApiErrorCode = 'unknown' | 'email_not_confirmed' | 'user_not_found' | 'user_already_exists'
export class ApiError extends Error {
code: ApiErrorCode
@ -65,12 +65,26 @@ export const apiClient = {
return response.data.signIn
},
authRegister: async ({ email, password = '', name = '' }): Promise<AuthResult> => {
// NOTE: name is to display
authRegister: async ({
email,
password,
name
}: {
email: string
password: string
name: string
}): Promise<void> => {
const response = await publicGraphQLClient
.mutation(authRegisterMutation, { email, password, name })
.toPromise()
return response.data.registerUser
if (response.error) {
if (response.error.message === '[GraphQL] User already exist') {
throw new ApiError('user_already_exists', response.error.message)
}
throw new ApiError('unknown', response.error.message)
}
},
authSignOut: async () => {
const response = await publicGraphQLClient.query(authLogoutQuery, {}).toPromise()
@ -79,6 +93,7 @@ export const apiClient = {
authCheckEmail: async ({ email }) => {
// check if email is used
const response = await publicGraphQLClient.query(authCheckEmailQuery, { email }).toPromise()
log.debug('authCheckEmail', response)
return response.data.isEmailUsed
},
authSendLink: async ({ email }) => {
@ -86,10 +101,21 @@ export const apiClient = {
const response = await publicGraphQLClient.query(authSendLinkMutation, { email }).toPromise()
return response.data.reset
},
authConfirmCode: async ({ code }) => {
confirmEmail: async ({ token }: { token: string }) => {
// confirm email with code from link
const response = await publicGraphQLClient.query(authConfirmCodeMutation, { code }).toPromise()
return response.data.reset
const response = await publicGraphQLClient
.mutation(authConfirmEmailMutation, { code: token })
.toPromise()
if (response.error) {
throw new ApiError('unknown', response.error.message)
}
if (response.data?.confirmEmail?.error) {
throw new ApiError('unknown', response.data?.confirmEmail?.error)
}
return response.data.confirmEmail
},
getTopArticles: async () => {
@ -216,6 +242,16 @@ export const apiClient = {
getSession: async (): Promise<AuthResult> => {
// renew session with auth token in header (!)
const response = await privateGraphQLClient.mutation(mySession, {}).toPromise()
if (response.error) {
// TODO
throw new ApiError('unknown', response.error.message)
}
if (response.data?.refreshSession?.error) {
throw new ApiError('unknown', response.data.refreshSession.error)
}
return response.data.refreshSession
},
getPublishedArticles: async ({ limit = FEED_SIZE, offset }: { limit?: number; offset?: number }) => {

View File

@ -1 +1,4 @@
export const isDev = import.meta.env.MODE === 'development'
// export const apiBaseUrl = 'https://newapi.discours.io'
export const apiBaseUrl = 'http://localhost:8080'

View File

@ -1,29 +0,0 @@
import * as Yup from 'yup'
const validators = {
'sign-in': [
{ username: '', password: '' },
{
username: Yup.string().required(),
password: Yup.string().required()
}
],
'sign-up': [
{ username: '', password: '', email: '' },
{
username: Yup.string(),
email: Yup.string().email().required(),
password: Yup.string().required()
}
],
forget: [{ email: '' }, { email: Yup.string().email().required() }],
reset: [{ code: '' }, { code: Yup.string().required() }],
resend: [{ email: '' }, { email: Yup.string().email().email().required() }],
password: [
{ password: '', password2: '' },
{ password: Yup.string().required(), password2: Yup.string().required() }
]
}
export const useValidator = (name: string) => {
return validators[name] || []
}