Merge branch 'dev' of https://github.com/Discours/discoursio-webapp into fix/topic-header

This commit is contained in:
kvakazyambra 2024-05-21 00:31:38 +03:00
commit 27d3496423
26 changed files with 528 additions and 281 deletions

View File

@ -11,7 +11,7 @@
"deploy": "graphql-codegen && npm run typecheck && vite build && vercel", "deploy": "graphql-codegen && npm run typecheck && vite build && vercel",
"dev": "vite", "dev": "vite",
"e2e": "npx playwright test --project=chromium", "e2e": "npx playwright test --project=chromium",
"fix": "npm run lint:code:fix && stylelint **/*.{scss,css} --fix", "fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix",
"format": "npx @biomejs/biome format src/. --write", "format": "npx @biomejs/biome format src/. --write",
"hygen": "HYGEN_TMPLS=gen hygen", "hygen": "HYGEN_TMPLS=gen hygen",
"postinstall": "npm run codegen && npx patch-package", "postinstall": "npm run codegen && npx patch-package",

View File

@ -91,6 +91,7 @@
"Community Principles": "Community Principles", "Community Principles": "Community Principles",
"Community values and rules of engagement for the open editorial team": "Community values and rules of engagement for the open editorial team", "Community values and rules of engagement for the open editorial team": "Community values and rules of engagement for the open editorial team",
"Confirm": "Confirm", "Confirm": "Confirm",
"Contents": "Contents",
"Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom", "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom",
"Cooperate": "Cooperate", "Cooperate": "Cooperate",
"Copy link": "Copy link", "Copy link": "Copy link",
@ -417,6 +418,7 @@
"Username": "Username", "Username": "Username",
"Userpic": "Userpic", "Userpic": "Userpic",
"Users": "Users", "Users": "Users",
"User was not found": "User was not found",
"Video format not supported": "Video format not supported", "Video format not supported": "Video format not supported",
"Video": "Video", "Video": "Video",
"Views": "Views", "Views": "Views",
@ -469,7 +471,6 @@
"cancel": "cancel", "cancel": "cancel",
"collections": "collections", "collections": "collections",
"community": "community", "community": "community",
"contents": "contents",
"delimiter": "delimiter", "delimiter": "delimiter",
"discussion": "Discours", "discussion": "Discours",
"dogma keywords": "Discours.io, dogma, editorial principles, code of ethics, journalism, community", "dogma keywords": "Discours.io, dogma, editorial principles, code of ethics, journalism, community",
@ -531,5 +532,13 @@
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password", "It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"Restore password": "Restore password", "Restore password": "Restore password",
"Subscribing...": "Subscribing...", "Subscribing...": "Subscribing...",
"Unsubscribing...": "Unsubscribing..." "Unsubscribing...": "Unsubscribing...",
"Login and security": "Login and security",
"Settings for account, email, password and login methods.": "Settings for account, email, password and login methods.",
"Current password": "Current password",
"Confirm your new password": "Confirm your new password",
"Connect": "Connect",
"Incorrect old password": "Incorrect old password",
"Repeat new password": "Repeat new password",
"Incorrect new password confirm": "Incorrect new password confirm"
} }

View File

@ -95,6 +95,7 @@
"Community Principles": "Принципы сообщества", "Community Principles": "Принципы сообщества",
"Community values and rules of engagement for the open editorial team": "Ценности сообщества и правила взаимодействия открытой редакции", "Community values and rules of engagement for the open editorial team": "Ценности сообщества и правила взаимодействия открытой редакции",
"Confirm": "Подтвердить", "Confirm": "Подтвердить",
"Contents": "Оглавление",
"Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Внесите вклад в свободный самиздат. Поддержите Дискурс — независимое некоммерческое издание, которое работает только для вас. Станьте опорой открытой редакции", "Contribute to free samizdat. Support Discours - an independent non-profit publication that works only for you. Become a pillar of the open newsroom": "Внесите вклад в свободный самиздат. Поддержите Дискурс — независимое некоммерческое издание, которое работает только для вас. Станьте опорой открытой редакции",
"Cooperate": "Соучаствовать", "Cooperate": "Соучаствовать",
"Copy link": "Скопировать ссылку", "Copy link": "Скопировать ссылку",
@ -492,7 +493,6 @@
"cancel": "отменить", "cancel": "отменить",
"collections": "коллекции", "collections": "коллекции",
"community": "сообщество", "community": "сообщество",
"contents": "оглавление",
"create_chat": "Создать чат", "create_chat": "Создать чат",
"create_group": "Создать группу", "create_group": "Создать группу",
"delimiter": "разделитель", "delimiter": "разделитель",
@ -549,6 +549,7 @@
"topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования", "topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования",
"topics": "темы", "topics": "темы",
"user already exist": "пользователь уже существует", "user already exist": "пользователь уже существует",
"User was not found": "Пользователь не найден",
"verified": "уже подтверждён", "verified": "уже подтверждён",
"video": "видео", "video": "видео",
"view": "просмотр", "view": "просмотр",
@ -558,5 +559,13 @@
"It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля", "It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля",
"Restore password": "Восстановить пароль", "Restore password": "Восстановить пароль",
"Subscribing...": "Подписываем...", "Subscribing...": "Подписываем...",
"Unsubscribing...": "Отписываем..." "Unsubscribing...": "Отписываем...",
"Login and security": "Вход и безопасность",
"Settings for account, email, password and login methods.": "Настройки аккаунта, почты, пароля и способов входа.",
"Current password": "Текущий пароль",
"Confirm your new password": "Подтвердите новый пароль",
"Connect": "Привязать",
"Incorrect old password": "Старый пароль не верен",
"Repeat new password": "Повторите новый пароль",
"Incorrect new password confirm": "Неверное подтверждение нового пароля"
} }

View File

@ -538,7 +538,7 @@ export const FullArticle = (props: Props) => {
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<div class={styles.shoutStatsItem} ref={triggerRef}> <div class={styles.shoutStatsItem} ref={triggerRef}>
<a <a
href={getPagePath(router, 'edit', { shoutId: props.article.id.toString() })} href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}
class={styles.shoutStatsItemInner} class={styles.shoutStatsItemInner}
> >
<Icon name="pencil-outline" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />

View File

@ -54,7 +54,7 @@ export const AuthorBadge = (props: Props) => {
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, 'inbox') openPage(router, 'inbox')
changeSearchParams({ changeSearchParams({
initChat: props.author.id.toString(), initChat: props.author?.id.toString(),
}) })
}, 'discussions') }, 'discussions')
} }

View File

@ -66,7 +66,7 @@ export const AuthorCard = (props: Props) => {
requireAuthentication(() => { requireAuthentication(() => {
openPage(router, 'inbox') openPage(router, 'inbox')
changeSearchParams({ changeSearchParams({
initChat: props.author.id.toString(), initChat: props.author?.id.toString(),
}) })
}, 'discussions') }, 'discussions')
} }

View File

@ -60,7 +60,7 @@ export const Draft = (props: Props) => {
<div class={styles.actions}> <div class={styles.actions}>
<a <a
class={styles.actionItem} class={styles.actionItem}
href={getPagePath(router, 'edit', { shoutId: props.shout.id.toString() })} href={getPagePath(router, 'edit', { shoutId: props.shout?.id.toString() })}
> >
{t('Edit')} {t('Edit')}
</a> </a>

View File

@ -48,11 +48,13 @@ type Props = {
onChange?: (text: string) => void onChange?: (text: string) => void
variant?: 'minimal' | 'bordered' variant?: 'minimal' | 'bordered'
maxLength?: number maxLength?: number
noLimits?: boolean
maxHeight?: number maxHeight?: number
submitButtonText?: string submitButtonText?: string
quoteEnabled?: boolean quoteEnabled?: boolean
imageEnabled?: boolean imageEnabled?: boolean
setClear?: boolean setClear?: boolean
resetToInitial?: boolean
smallHeight?: boolean smallHeight?: boolean
submitByCtrlEnter?: boolean submitByCtrlEnter?: boolean
onlyBubbleControls?: boolean onlyBubbleControls?: boolean
@ -124,7 +126,7 @@ const SimplifiedEditor = (props: Props) => {
openOnClick: false, openOnClick: false,
}), }),
CharacterCount.configure({ CharacterCount.configure({
limit: maxLength, limit: props.noLimits ? null : maxLength,
}), }),
Blockquote.configure({ Blockquote.configure({
HTMLAttributes: { HTMLAttributes: {
@ -216,6 +218,10 @@ const SimplifiedEditor = (props: Props) => {
if (props.setClear) { if (props.setClear) {
editor().commands.clearContent(true) editor().commands.clearContent(true)
} }
if (props.resetToInitial) {
editor().commands.clearContent(true)
editor().commands.setContent(props.initialContent)
}
}) })
const handleKeyDown = (event) => { const handleKeyDown = (event) => {

View File

@ -328,7 +328,7 @@ export const ArticleCard = (props: ArticleCardProps) => {
<Popover content={t('Edit')} disabled={isActionPopupActive()}> <Popover content={t('Edit')} disabled={isActionPopupActive()}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<div class={styles.shoutCardDetailsItem} ref={triggerRef}> <div class={styles.shoutCardDetailsItem} ref={triggerRef}>
<a href={getPagePath(router, 'edit', { shoutId: props.article.id.toString() })}> <a href={getPagePath(router, 'edit', { shoutId: props.article?.id.toString() })}>
<Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} /> <Icon name="pencil-outline" class={clsx(styles.icon, styles.feedControlIcon)} />
<Icon <Icon
name="pencil-outline-hover" name="pencil-outline-hover"

View File

@ -95,33 +95,37 @@ export const LoginForm = () => {
try { try {
const { errors } = await signIn({ email: email(), password: password() }) const { errors } = await signIn({ email: email(), password: password() })
console.error('[signIn errors]', errors)
if (errors?.length > 0) { if (errors?.length > 0) {
if ( console.warn('[signIn] errors: ', errors)
errors.some( errors.forEach((error) => {
(error) => switch (error.message) {
error.message.includes('bad user credentials') || error.message.includes('user not found'), case 'user has not signed up email & password': {
) setValidationErrors((prev) => ({
) { ...prev,
setValidationErrors((prev) => ({ password: t('Something went wrong, check email and password'),
...prev, }))
password: t('Something went wrong, check email and password'), break
})) }
} else if (errors.some((error) => error.message.includes('user not found'))) { case 'user not found': {
setSubmitError('Пользователь не найден') setValidationErrors((prev) => ({ ...prev, email: t('User was not found') }))
} else if (errors.some((error) => error.message.includes('email not verified'))) { break
setSubmitError( }
<div class={styles.info}> case 'email not verified': {
{t('This email is not verified')} setValidationErrors((prev) => ({ ...prev, email: t('This email is not verified') }))
{'. '} break
<span class={'link'} onClick={handleSendLinkAgainClick}> }
{t('Send link again')} default:
</span> setSubmitError(
</div>, <div class={styles.info}>
) {t('Error', errors[0].message)}
} else { {'. '}
setSubmitError(t('Error', errors[0].message)) <span class={'link'} onClick={handleSendLinkAgainClick}>
} {t('Send link again')}
</span>
</div>,
)
}
})
return return
} }
hideModal() hideModal()

View File

@ -16,6 +16,9 @@ type Props = {
onBlur?: (value: string) => void onBlur?: (value: string) => void
variant?: 'login' | 'registration' variant?: 'login' | 'registration'
disableAutocomplete?: boolean disableAutocomplete?: boolean
noValidate?: boolean
onFocus?: () => void
value?: string
} }
const minLength = 8 const minLength = 8
@ -27,7 +30,7 @@ export const PasswordField = (props: Props) => {
const [showPassword, setShowPassword] = createSignal(false) const [showPassword, setShowPassword] = createSignal(false)
const [error, setError] = createSignal<string>() const [error, setError] = createSignal<string>()
const validatePassword = (passwordToCheck) => { const validatePassword = (passwordToCheck: string) => {
if (passwordToCheck.length < minLength) { if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters') return t('Password should be at least 8 characters')
} }
@ -50,11 +53,13 @@ export const PasswordField = (props: Props) => {
} }
props.onInput(value) props.onInput(value)
const errorValue = validatePassword(value) if (!props.noValidate) {
if (errorValue) { const errorValue = validatePassword(value)
setError(errorValue) if (errorValue) {
} else { setError(errorValue)
setError() } else {
setError()
}
} }
} }
@ -78,6 +83,8 @@ export const PasswordField = (props: Props) => {
id="password" id="password"
name="password" name="password"
disabled={props.disabled} disabled={props.disabled}
onFocus={props.onFocus}
value={props.value ? props.value : ''}
autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'} autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'}
type={showPassword() ? 'text' : 'password'} type={showPassword() ? 'text' : 'password'}
placeholder={props.placeholder || t('Password')} placeholder={props.placeholder || t('Password')}

View File

@ -103,7 +103,13 @@ export const HeaderAuth = (props: Props) => {
<div class={clsx('col-auto col-lg-7', styles.usernav)}> <div class={clsx('col-auto col-lg-7', styles.usernav)}>
<div class={styles.userControl}> <div class={styles.userControl}>
<Show when={isCreatePostButtonVisible() && session()?.access_token}> <Show when={isCreatePostButtonVisible() && session()?.access_token}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div
class={clsx(
styles.userControlItem,
styles.userControlItemVerbose,
styles.userControlItemCreate,
)}
>
<a href={getPagePath(router, 'create')}> <a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span> <span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil-outline" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />
@ -210,11 +216,17 @@ export const HeaderAuth = (props: Props) => {
</Show> </Show>
<Show when={isCreatePostButtonVisible() && !session()?.access_token}> <Show when={isCreatePostButtonVisible() && !session()?.access_token}>
<div class={clsx(styles.userControlItem, styles.userControlItemVerbose)}> <div
class={clsx(
styles.userControlItem,
styles.userControlItemVerbose,
styles.userControlItemCreate,
)}
>
<a href={getPagePath(router, 'create')}> <a href={getPagePath(router, 'create')}>
<span class={styles.textLabel}>{t('Create post')}</span> <span class={styles.textLabel}>{t('Create post')}</span>
<Icon name="pencil" class={styles.icon} /> <Icon name="pencil-outline" class={styles.icon} />
<Icon name="pencil" class={clsx(styles.icon, styles.iconHover)} /> <Icon name="pencil-outline-hover" class={clsx(styles.icon, styles.iconHover)} />
</a> </a>
</div> </div>
</Show> </Show>
@ -227,7 +239,7 @@ export const HeaderAuth = (props: Props) => {
<a href="?m=auth&mode=login"> <a href="?m=auth&mode=login">
<span class={styles.textLabel}>{t('Enter')}</span> <span class={styles.textLabel}>{t('Enter')}</span>
<Icon name="key" class={styles.icon} /> <Icon name="key" class={styles.icon} />
{/*<Icon name="user-default" class={clsx(styles.icon, styles.iconHover)} />*/} <Icon name="key" class={clsx(styles.icon, styles.iconHover)} />
</a> </a>
</div> </div>
</Show> </Show>

View File

@ -20,6 +20,8 @@ import { useLocalize } from '../../context/localize'
import { useProfileForm } from '../../context/profile' import { useProfileForm } from '../../context/profile'
import { useSession } from '../../context/session' import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar' import { useSnackbar } from '../../context/snackbar'
import { ProfileInput } from '../../graphql/schema/core.gen'
import styles from '../../pages/profile/Settings.module.scss'
import { hideModal, showModal } from '../../stores/ui' import { hideModal, showModal } from '../../stores/ui'
import { clone } from '../../utils/clone' import { clone } from '../../utils/clone'
import { getImageUrl } from '../../utils/getImageUrl' import { getImageUrl } from '../../utils/getImageUrl'
@ -35,14 +37,12 @@ import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { SocialNetworkInput } from '../_shared/SocialNetworkInput' import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
import styles from '../../pages/profile/Settings.module.scss'
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea')) const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea'))
export const ProfileSettings = () => { export const ProfileSettings = () => {
const { t } = useLocalize() const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore({}) const [prevForm, setPrevForm] = createStore<ProfileInput>({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [isFormInitialized, setIsFormInitialized] = createSignal(false)
const [isSaving, setIsSaving] = createSignal(false) const [isSaving, setIsSaving] = createSignal(false)
const [social, setSocial] = createSignal([]) const [social, setSocial] = createSignal([])
@ -59,6 +59,7 @@ export const ProfileSettings = () => {
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { loadAuthor, session } = useSession() const { loadAuthor, session } = useSession()
const { showConfirm } = useConfirm() const { showConfirm } = useConfirm()
const [clearAbout, setClearAbout] = createSignal(false)
createEffect(() => { createEffect(() => {
if (Object.keys(form).length > 0 && !isFormInitialized()) { if (Object.keys(form).length > 0 && !isFormInitialized()) {
@ -121,7 +122,9 @@ export const ProfileSettings = () => {
declineButtonVariant: 'secondary', declineButtonVariant: 'secondary',
}) })
if (isConfirmed) { if (isConfirmed) {
setClearAbout(true)
setForm(clone(prevForm)) setForm(clone(prevForm))
setClearAbout(false)
} }
} }
@ -171,11 +174,13 @@ export const ProfileSettings = () => {
on( on(
() => deepEqual(form, prevForm), () => deepEqual(form, prevForm),
() => { () => {
setIsFloatingPanelVisible(!deepEqual(form, prevForm)) if (Object.keys(prevForm).length > 0) {
setIsFloatingPanelVisible(!deepEqual(form, prevForm))
}
}, },
{ defer: true },
), ),
) )
const handleDeleteSocialLink = (link) => { const handleDeleteSocialLink = (link) => {
updateFormField('links', link, true) updateFormField('links', link, true)
} }
@ -317,6 +322,8 @@ export const ProfileSettings = () => {
<h4>{t('About')}</h4> <h4>{t('About')}</h4>
<SimplifiedEditor <SimplifiedEditor
resetToInitial={clearAbout()}
noLimits={true}
variant="bordered" variant="bordered"
onlyBubbleControls={true} onlyBubbleControls={true}
smallHeight={true} smallHeight={true}

View File

@ -157,7 +157,7 @@
color: #000; color: #000;
font-size: 14px; font-size: 14px;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 500;
line-height: 1.8rem; line-height: 1.8rem;
text-align: left; text-align: left;
vertical-align: bottom; vertical-align: bottom;

View File

@ -86,7 +86,7 @@ export const TableOfContents = (props: Props) => {
<Show when={isVisible()}> <Show when={isVisible()}>
<div class={styles.TableOfContentsContainerInner}> <div class={styles.TableOfContentsContainerInner}>
<div class={styles.TableOfContentsHeader}> <div class={styles.TableOfContentsHeader}>
<p class={styles.TableOfContentsHeading}>{t('contents')}</p> <p class={styles.TableOfContentsHeading}>{t('Contents')}</p>
</div> </div>
<ul class={styles.TableOfContentsHeadingsList}> <ul class={styles.TableOfContentsHeadingsList}>
<For each={headings()}> <For each={headings()}>

View File

@ -45,7 +45,6 @@
.info { .info {
@include font-size(1.4rem); @include font-size(1.4rem);
border: none; border: none;
// display: flex; // display: flex;
@ -63,13 +62,11 @@
.title { .title {
@include font-size(2.2rem); @include font-size(2.2rem);
font-weight: bold; font-weight: bold;
} }
.description { .description {
@include font-size(1.6rem); @include font-size(1.6rem);
line-height: 1.4; line-height: 1.4;
margin: 0.8rem 0; margin: 0.8rem 0;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@ -107,7 +104,6 @@
.title { .title {
@include font-size(1.4rem); @include font-size(1.4rem);
font-weight: 500; font-weight: 500;
line-height: 1em; line-height: 1em;
color: var(--blue-500); color: var(--blue-500);
@ -116,9 +112,7 @@
.description { .description {
color: var(--black-400); color: var(--black-400);
@include font-size(1.2rem); @include font-size(1.2rem);
font-weight: 500; font-weight: 500;
margin: 0; margin: 0;
} }

View File

@ -48,7 +48,7 @@ export const TopicBadge = (props: Props) => {
lang() === 'en' ? capitalize(props.topic.slug.replaceAll('-', ' ')) : props.topic.title lang() === 'en' ? capitalize(props.topic.slug.replaceAll('-', ' ')) : props.topic.title
return ( return (
<div class={clsx(styles.TopicBadge, props.subscriptionsMode)}> <div class={clsx(styles.TopicBadge, { [styles.TopicBadgeSubscriptionsMode]: props.subscriptionsMode })}>
<div class={styles.content}> <div class={styles.content}>
<div class={styles.basicInfo}> <div class={styles.basicInfo}>
<Show when={props.subscriptionsMode}> <Show when={props.subscriptionsMode}>

View File

@ -3,10 +3,10 @@ import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/cor
import { getPagePath } from '@nanostores/router' import { getPagePath } from '@nanostores/router'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js' import { For, Match, Show, Switch, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
import { Meta, Title } from '../../../context/meta'
import { useFollowing } from '../../../context/following' import { useFollowing } from '../../../context/following'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
import { Meta, Title } from '../../../context/meta'
import { useSession } from '../../../context/session' import { useSession } from '../../../context/session'
import { apiClient } from '../../../graphql/client/core' import { apiClient } from '../../../graphql/client/core'
import { router, useRouter } from '../../../stores/router' import { router, useRouter } from '../../../stores/router'
@ -76,24 +76,17 @@ export const AuthorView = (props: Props) => {
const fetchData = async (slug: string) => { const fetchData = async (slug: string) => {
try { try {
const [followsResult, followersResult, authorResult] = await Promise.all([ const [subscriptionsResult, followersResult, authorResult] = await Promise.all([
apiClient.getAuthorFollows({ slug }), apiClient.getAuthorFollows({ slug }),
apiClient.getAuthorFollowers({ slug }), apiClient.getAuthorFollowers({ slug }),
loadAuthor({ slug }), loadAuthor({ slug }),
]) ])
const { authors, topics, error } = followsResult const { authors, topics } = subscriptionsResult
if (error) {
console.error(error)
return
}
console.debug(authorResult)
setAuthor(authorResult) setAuthor(authorResult)
console.debug(authors, topics)
setFollowing([...(authors || []), ...(topics || [])]) setFollowing([...(authors || []), ...(topics || [])])
console.debug(followersResult)
setFollowers(followersResult || []) setFollowers(followersResult || [])
console.debug('[components.Author] author follows:', followsResult) console.info('[components.Author] data loaded')
} catch (error) { } catch (error) {
console.error('[components.Author] fetch error', error) console.error('[components.Author] fetch error', error)
} }
@ -250,7 +243,7 @@ export const AuthorView = (props: Props) => {
class={styles.longBio} class={styles.longBio}
classList={{ [styles.longBioExpanded]: isBioExpanded() }} classList={{ [styles.longBioExpanded]: isBioExpanded() }}
> >
<div ref={(el) => (bioContainerRef.current = el)} innerHTML={author().about} /> <div ref={(el) => (bioContainerRef.current = el)} innerHTML={author()?.about || ''} />
</div> </div>
<Show when={showExpandBioControl()}> <Show when={showExpandBioControl()}>

View File

@ -2,7 +2,7 @@ import { clsx } from 'clsx'
import deepEqual from 'fast-deep-equal' import deepEqual from 'fast-deep-equal'
import { Accessor, Show, createMemo, createSignal, lazy, onCleanup, onMount } from 'solid-js' import { Accessor, Show, createMemo, createSignal, lazy, onCleanup, onMount } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { throttle } from 'throttle-debounce' import { debounce } from 'throttle-debounce'
import { ShoutForm, useEditorContext } from '../../../context/editor' import { ShoutForm, useEditorContext } from '../../../context/editor'
import { useLocalize } from '../../../context/localize' import { useLocalize } from '../../../context/localize'
@ -42,9 +42,8 @@ export const EMPTY_TOPIC: Topic = {
slug: '', slug: '',
} }
const THROTTLING_INTERVAL = 2000 const AUTO_SAVE_DELAY = 3000
const AUTO_SAVE_INTERVAL = 5000
const AUTO_SAVE_DELAY = 5000
const handleScrollTopButtonClick = (e) => { const handleScrollTopButtonClick = (e) => {
e.preventDefault() e.preventDefault()
window.scrollTo({ window.scrollTo({
@ -104,6 +103,8 @@ export const EditView = (props: Props) => {
return JSON.parse(form.media || '[]') return JSON.parse(form.media || '[]')
}) })
const [hasChanges, setHasChanges] = createSignal(false)
onMount(() => { onMount(() => {
const handleScroll = () => { const handleScroll = () => {
setIsScrolled(window.scrollY > 0) setIsScrolled(window.scrollY > 0)
@ -113,7 +114,7 @@ export const EditView = (props: Props) => {
onCleanup(() => { onCleanup(() => {
window.removeEventListener('scroll', handleScroll) window.removeEventListener('scroll', handleScroll)
}) })
// eslint-disable-next-line unicorn/consistent-function-scoping
const handleBeforeUnload = (event) => { const handleBeforeUnload = (event) => {
if (!deepEqual(prevForm, form)) { if (!deepEqual(prevForm, form)) {
event.returnValue = t( event.returnValue = t(
@ -127,8 +128,8 @@ export const EditView = (props: Props) => {
}) })
const handleTitleInputChange = (value: string) => { const handleTitleInputChange = (value: string) => {
setForm('title', value) handleInputChange('title', value)
setForm('slug', slugify(value)) handleInputChange('slug', slugify(value))
if (value) { if (value) {
setFormErrors('title', '') setFormErrors('title', '')
} }
@ -136,21 +137,21 @@ export const EditView = (props: Props) => {
const handleAddMedia = (data) => { const handleAddMedia = (data) => {
const newMedia = [...mediaItems(), ...data] const newMedia = [...mediaItems(), ...data]
setForm('media', JSON.stringify(newMedia)) handleInputChange('media', JSON.stringify(newMedia))
} }
const handleSortedMedia = (data) => { const handleSortedMedia = (data) => {
setForm('media', JSON.stringify(data)) handleInputChange('media', JSON.stringify(data))
} }
const handleMediaDelete = (index) => { const handleMediaDelete = (index) => {
const copy = [...mediaItems()] const copy = [...mediaItems()]
copy.splice(index, 1) copy.splice(index, 1)
setForm('media', JSON.stringify(copy)) handleInputChange('media', JSON.stringify(copy))
} }
const handleMediaChange = (index, value) => { const handleMediaChange = (index, value) => {
const updated = mediaItems().map((item, idx) => (idx === index ? value : item)) const updated = mediaItems().map((item, idx) => (idx === index ? value : item))
setForm('media', JSON.stringify(updated)) handleInputChange('media', JSON.stringify(updated))
} }
const [baseAudioFields, setBaseAudioFields] = createSignal({ const [baseAudioFields, setBaseAudioFields] = createSignal({
@ -162,7 +163,7 @@ export const EditView = (props: Props) => {
const handleBaseFieldsChange = (key, value) => { const handleBaseFieldsChange = (key, value) => {
if (mediaItems().length > 0) { if (mediaItems().length > 0) {
const updated = mediaItems().map((media) => ({ ...media, [key]: value })) const updated = mediaItems().map((media) => ({ ...media, [key]: value }))
setForm('media', JSON.stringify(updated)) handleInputChange('media', JSON.stringify(updated))
} else { } else {
setBaseAudioFields({ ...baseAudioFields(), [key]: value }) setBaseAudioFields({ ...baseAudioFields(), [key]: value })
} }
@ -182,34 +183,32 @@ export const EditView = (props: Props) => {
} }
} }
let autoSaveTimeOutId: number | string | NodeJS.Timeout
const autoSave = async () => { const autoSave = async () => {
const hasChanges = !deepEqual(form, prevForm) console.log('autoSave called')
const hasTopic = Boolean(form.mainTopic) if (hasChanges()) {
if (hasChanges || hasTopic) {
console.debug('saving draft', form) console.debug('saving draft', form)
setSaving(true) setSaving(true)
saveDraftToLocalStorage(form) saveDraftToLocalStorage(form)
await saveDraft(form) await saveDraft(form)
setPrevForm(clone(form)) setPrevForm(clone(form))
setTimeout(() => setSaving(false), AUTO_SAVE_DELAY) setSaving(false)
setHasChanges(false)
} }
} }
// Throttle the autoSave function const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, autoSave)
const throttledAutoSave = throttle(THROTTLING_INTERVAL, autoSave)
const autoSaveRecursive = () => { const handleInputChange = (key, value) => {
autoSaveTimeOutId = setTimeout(() => { console.log(`[handleInputChange] ${key}: ${value}`)
throttledAutoSave() setForm(key, value)
autoSaveRecursive() setHasChanges(true)
}, AUTO_SAVE_INTERVAL) debouncedAutoSave()
} }
onMount(() => { onMount(() => {
autoSaveRecursive() onCleanup(() => {
onCleanup(() => clearTimeout(autoSaveTimeOutId)) debouncedAutoSave.cancel()
})
}) })
const showSubtitleInput = () => { const showSubtitleInput = () => {
@ -310,7 +309,7 @@ export const EditView = (props: Props) => {
subtitleInput.current = el subtitleInput.current = el
}} }}
allowEnterKey={false} allowEnterKey={false}
value={(value) => setForm('subtitle', value || '')} value={(value) => handleInputChange('subtitle', value || '')}
class={styles.subtitleInput} class={styles.subtitleInput}
placeholder={t('Subheader')} placeholder={t('Subheader')}
initialValue={form.subtitle || ''} initialValue={form.subtitle || ''}
@ -324,7 +323,7 @@ export const EditView = (props: Props) => {
smallHeight={true} smallHeight={true}
placeholder={t('A short introduction to keep the reader interested')} placeholder={t('A short introduction to keep the reader interested')}
initialContent={form.lead} initialContent={form.lead}
onChange={(value) => setForm('lead', value)} onChange={(value) => handleInputChange('lead', value)}
/> />
</Show> </Show>
</Show> </Show>
@ -345,7 +344,7 @@ export const EditView = (props: Props) => {
} }
isMultiply={false} isMultiply={false}
fileType={'image'} fileType={'image'}
onUpload={(val) => setForm('coverImageUrl', val[0].url)} onUpload={(val) => handleInputChange('coverImageUrl', val[0].url)}
/> />
} }
> >
@ -362,7 +361,7 @@ export const EditView = (props: Props) => {
<div <div
ref={triggerRef} ref={triggerRef}
class={styles.delete} class={styles.delete}
onClick={() => setForm('coverImageUrl', null)} onClick={() => handleInputChange('coverImageUrl', null)}
> >
<Icon name="close-white" /> <Icon name="close-white" />
</div> </div>
@ -408,7 +407,7 @@ export const EditView = (props: Props) => {
<Editor <Editor
shoutId={form.shoutId} shoutId={form.shoutId}
initialContent={form.body} initialContent={form.body}
onChange={(body) => setForm('body', body)} onChange={(body) => handleInputChange('body', body)}
/> />
</Show> </Show>
</div> </div>

View File

@ -37,7 +37,6 @@ export const ArticleCardSwiper = (props: Props) => {
[styles.Swiper]: props.slides.length > 1, [styles.Swiper]: props.slides.length > 1,
[styles.articleMode]: true, [styles.articleMode]: true,
[styles.ArticleCardSwiper]: props.slides.length > 1, [styles.ArticleCardSwiper]: props.slides.length > 1,
[styles.unswiped]: props.slides.length === 1,
})} })}
> >
<Show when={props.title}> <Show when={props.title}>

View File

@ -30,53 +30,57 @@ const ConnectContext = createContext<ConnectContextType>()
export const ConnectProvider = (props: { children: JSX.Element }) => { export const ConnectProvider = (props: { children: JSX.Element }) => {
const [messageHandlers, setHandlers] = createSignal<MessageHandler[]>([]) const [messageHandlers, setHandlers] = createSignal<MessageHandler[]>([])
// const [messages, setMessages] = createSignal<Array<SSEMessage>>([]);
const [connected, setConnected] = createSignal(false) const [connected, setConnected] = createSignal(false)
const { session } = useSession() const { session } = useSession()
const [retried, setRetried] = createSignal<number>(0)
const addHandler = (handler: MessageHandler) => { const addHandler = (handler: MessageHandler) => {
setHandlers((hhh) => [...hhh, handler]) setHandlers((hhh) => [...hhh, handler])
} }
const [retried, setRetried] = createSignal<number>(0)
createEffect(async () => { createEffect(async () => {
const token = session()?.access_token const token = session()?.access_token
if (token && !connected()) { if (token && !connected() && retried() <= RECONNECT_TIMES) {
console.info('[context.connect] init SSE connection') console.info('[context.connect] init SSE connection')
await fetchEventSource('https://connect.discours.io', { try {
method: 'GET', await fetchEventSource('https://connect.discours.io', {
headers: { method: 'GET',
'Content-Type': 'application/json', headers: {
Authorization: token, 'Content-Type': 'application/json',
}, Authorization: token,
onmessage(event) { },
const m: SSEMessage = JSON.parse(event.data || '{}') onmessage(event) {
console.log('[context.connect] Received message:', m) const m: SSEMessage = JSON.parse(event.data || '{}')
console.log('[context.connect] Received message:', m)
// Iterate over all registered handlers and call them messageHandlers().forEach((handler) => handler(m))
messageHandlers().forEach((handler) => handler(m)) },
}, onopen: (response) => {
async onopen(response) { console.log('[context.connect] SSE connection opened', response)
console.log('[context.connect] SSE connection opened', response) if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
if (response.ok && response.headers.get('content-type') === EventStreamContentType) { setConnected(true)
setConnected(true) setRetried(0)
} else if (response.status === 401) { return Promise.resolve()
throw new Error('unauthorized') }
} else { return Promise.reject(`SSE: cannot connect to real-time updates, status: ${response.status}`)
setRetried((r) => r + 1) },
throw new Error('Internal Error') onclose() {
} console.log('[context.connect] SSE connection closed by server')
}, setConnected(false)
onclose() { if (retried() < RECONNECT_TIMES) {
console.log('[context.connect] SSE connection closed by server') setRetried((r) => r + 1)
setConnected(false) }
}, },
onerror(err) { onerror(err) {
if (err.message === 'unauthorized' || retried() > RECONNECT_TIMES) { console.error('[context.connect] SSE connection error:', err)
throw err // rethrow to stop the operation setConnected(false)
} if (retried() < RECONNECT_TIMES) {
}, setRetried((r) => r + 1)
}) } else throw Error(err)
},
})
} catch (error) {
console.error('[context.connect] SSE connection failed:', error)
}
} }
}) })

View File

@ -13,6 +13,7 @@ import {
LoginInput, LoginInput,
ResendVerifyEmailInput, ResendVerifyEmailInput,
SignupInput, SignupInput,
UpdateProfileInput,
VerifyEmailInput, VerifyEmailInput,
} from '@authorizerdev/authorizer-js' } from '@authorizerdev/authorizer-js'
import { import {
@ -58,6 +59,7 @@ export type SessionContextType = {
) => void ) => void
signUp: (params: SignupInput) => Promise<{ data: AuthToken; errors: Error[] }> signUp: (params: SignupInput) => Promise<{ data: AuthToken; errors: Error[] }>
signIn: (params: LoginInput) => Promise<{ data: AuthToken; errors: Error[] }> signIn: (params: LoginInput) => Promise<{ data: AuthToken; errors: Error[] }>
updateProfile: (params: UpdateProfileInput) => Promise<{ data: AuthToken; errors: Error[] }>
signOut: () => Promise<void> signOut: () => Promise<void>
oauth: (provider: string) => Promise<void> oauth: (provider: string) => Promise<void>
forgotPassword: ( forgotPassword: (
@ -223,9 +225,12 @@ export const SessionProvider = (props: {
const appdata = session()?.user.app_data const appdata = session()?.user.app_data
if (appdata) { if (appdata) {
const { profile } = appdata const { profile } = appdata
setAuthor(profile) if (profile?.id) {
addAuthors([profile]) setAuthor(profile)
if (!profile) loadAuthor() addAuthors([profile])
} else {
setTimeout(loadAuthor, 15)
}
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -305,6 +310,8 @@ export const SessionProvider = (props: {
} }
const signUp = async (params: SignupInput) => await authenticate(authorizer().signup, params) const signUp = async (params: SignupInput) => await authenticate(authorizer().signup, params)
const signIn = async (params: LoginInput) => await authenticate(authorizer().login, params) const signIn = async (params: LoginInput) => await authenticate(authorizer().login, params)
const updateProfile = async (params: UpdateProfileInput) =>
await authenticate(authorizer().updateProfile, params)
const signOut = async () => { const signOut = async () => {
const authResult: ApiResponse<GenericResponse> = await authorizer().logout() const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
@ -381,6 +388,7 @@ export const SessionProvider = (props: {
signIn, signIn,
signOut, signOut,
confirmEmail, confirmEmail,
updateProfile,
setIsSessionLoaded, setIsSessionLoaded,
setSession, setSession,
setAuthor, setAuthor,

View File

@ -18,7 +18,7 @@ import styles from '../styles/Create.module.scss'
const handleCreate = async (layout: LayoutType) => { const handleCreate = async (layout: LayoutType) => {
const shout = await apiClient.createArticle({ article: { layout: layout } }) const shout = await apiClient.createArticle({ article: { layout: layout } })
redirectPage(router, 'edit', { redirectPage(router, 'edit', {
shoutId: shout.id.toString(), shoutId: shout?.id.toString(),
}) })
} }

View File

@ -1,4 +1,4 @@
import { Show, Suspense, createEffect, createMemo, createSignal, lazy, on, onMount } from 'solid-js' import { Show, Suspense, createEffect, createMemo, createSignal, lazy, on } from 'solid-js'
import { AuthGuard } from '../components/AuthGuard' import { AuthGuard } from '../components/AuthGuard'
import { Loading } from '../components/_shared/Loading' import { Loading } from '../components/_shared/Loading'
@ -7,7 +7,7 @@ import { useLocalize } from '../context/localize'
import { useSession } from '../context/session' import { useSession } from '../context/session'
import { apiClient } from '../graphql/client/core' import { apiClient } from '../graphql/client/core'
import { Shout } from '../graphql/schema/core.gen' import { Shout } from '../graphql/schema/core.gen'
import { router } from '../stores/router' import { router, useRouter } from '../stores/router'
import { redirectPage } from '@nanostores/router' import { redirectPage } from '@nanostores/router'
import { useSnackbar } from '../context/snackbar' import { useSnackbar } from '../context/snackbar'
@ -33,6 +33,7 @@ const getContentTypeTitle = (layout: LayoutType) => {
export const EditPage = () => { export const EditPage = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { session } = useSession() const { session } = useSession()
const { page } = useRouter()
const snackbar = useSnackbar() const snackbar = useSnackbar()
const fail = async (error: string) => { const fail = async (error: string) => {
@ -45,12 +46,22 @@ export const EditPage = () => {
const [shoutId, setShoutId] = createSignal<number>(0) const [shoutId, setShoutId] = createSignal<number>(0)
const [shout, setShout] = createSignal<Shout>() const [shout, setShout] = createSignal<Shout>()
onMount(() => { createEffect(
const shoutId = window.location.pathname.split('/').pop() on(
const shoutIdFromUrl = Number.parseInt(shoutId ?? '0', 10) () => page(),
console.debug(`editing shout ${shoutIdFromUrl}`) (p) => {
if (shoutIdFromUrl) setShoutId(shoutIdFromUrl) if (p?.path) {
}) console.debug(p?.path)
const shoutId = p?.path.split('/').pop()
const shoutIdFromUrl = Number.parseInt(shoutId ?? '0', 10)
console.debug(`editing shout ${shoutIdFromUrl}`)
if (shoutIdFromUrl) {
setShoutId(shoutIdFromUrl)
}
}
},
),
)
createEffect( createEffect(
on([session, shout, shoutId], async ([ses, sh, shid]) => { on([session, shout, shoutId], async ([ses, sh, shid]) => {
@ -63,6 +74,7 @@ export const EditPage = () => {
} }
} }
}), }),
{ defer: true },
) )
const title = createMemo(() => { const title = createMemo(() => {

View File

@ -100,17 +100,6 @@ h5 {
} }
} }
.passwordToggleControl {
position: absolute;
right: 1em;
transform: translateY(-50%);
top: 50%;
}
.passwordInput {
padding-right: 3em !important;
}
.searchContainer { .searchContainer {
margin-top: 2.4rem; margin-top: 2.4rem;
} }
@ -331,3 +320,12 @@ div[data-lastpass-infield="true"] {
opacity: 0 !important; opacity: 0 !important;
} }
.emailValidationError {
position: absolute;
top: 100%;
font-size: 12px;
line-height: 16px;
margin-top: 0.3em;
color: var(--danger-color);
}

View File

@ -6,135 +6,321 @@ import { Icon } from '../../components/_shared/Icon'
import { PageLayout } from '../../components/_shared/PageLayout' import { PageLayout } from '../../components/_shared/PageLayout'
import { useLocalize } from '../../context/localize' import { useLocalize } from '../../context/localize'
import { UpdateProfileInput } from '@authorizerdev/authorizer-js'
import { Show, createEffect, createSignal, on } from 'solid-js'
import { PasswordField } from '../../components/Nav/AuthModal/PasswordField'
import { Button } from '../../components/_shared/Button'
import { Loading } from '../../components/_shared/Loading'
import { useConfirm } from '../../context/confirm'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { validateEmail } from '../../utils/validateEmail'
import styles from './Settings.module.scss' import styles from './Settings.module.scss'
type FormField = 'oldPassword' | 'newPassword' | 'newPasswordConfirm' | 'email'
export const ProfileSecurityPage = () => { export const ProfileSecurityPage = () => {
const { t } = useLocalize() const { t } = useLocalize()
const { updateProfile, session, isSessionLoaded } = useSession()
const { showSnackbar } = useSnackbar()
const { showConfirm } = useConfirm()
const [newPasswordError, setNewPasswordError] = createSignal<string | undefined>()
const [oldPasswordError, setOldPasswordError] = createSignal<string | undefined>()
const [emailError, setEmailError] = createSignal<string | undefined>()
const [isSubmitting, setIsSubmitting] = createSignal<boolean>()
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
const initialState = {
oldPassword: undefined,
newPassword: undefined,
newPasswordConfirm: undefined,
email: undefined,
}
const [formData, setFormData] = createSignal(initialState)
const oldPasswordRef: { current: HTMLDivElement } = { current: null }
const newPasswordRepeatRef: { current: HTMLDivElement } = { current: null }
createEffect(
on(
() => session()?.user?.email,
() => {
setFormData((prevData) => ({
...prevData,
['email']: session()?.user?.email,
}))
},
),
)
const handleInputChange = (name: FormField, value: string) => {
if (
name === 'email' ||
(name === 'newPasswordConfirm' && value && value?.length > 0 && !emailError() && !newPasswordError())
) {
setIsFloatingPanelVisible(true)
} else {
setIsFloatingPanelVisible(false)
}
setFormData((prevData) => ({
...prevData,
[name]: value,
}))
}
const handleCancel = async () => {
const isConfirmed = await showConfirm({
confirmBody: t('Do you really want to reset all changes?'),
confirmButtonVariant: 'primary',
declineButtonVariant: 'secondary',
})
if (isConfirmed) {
setEmailError()
setFormData({
...initialState,
['email']: session()?.user?.email,
})
setIsFloatingPanelVisible(false)
}
}
const handleChangeEmail = (_value: string) => {
if (!validateEmail(formData()['email'])) {
setEmailError(t('Invalid email'))
return
}
}
const handleCheckNewPassword = (value: string) => {
handleInputChange('newPasswordConfirm', value)
if (value !== formData()['newPassword']) {
const rect = newPasswordRepeatRef.current.getBoundingClientRect()
const topPosition = window.scrollY + rect.top - DEFAULT_HEADER_OFFSET * 2
window.scrollTo({
top: topPosition,
left: 0,
behavior: 'smooth',
})
showSnackbar({ type: 'error', body: t('Incorrect new password confirm') })
setNewPasswordError(t('Passwords are not equal'))
}
}
const handleSubmit = async () => {
setIsSubmitting(true)
const options: UpdateProfileInput = {
old_password: formData()['oldPassword'],
new_password: formData()['newPassword'] || formData()['oldPassword'],
confirm_new_password: formData()['newPassword'] || formData()['oldPassword'],
email: formData()['email'],
}
try {
const { errors } = await updateProfile(options)
if (errors.length > 0) {
console.error(errors)
if (errors.some((obj) => obj.message === 'incorrect old password')) {
setOldPasswordError(t('Incorrect old password'))
showSnackbar({ type: 'error', body: t('Incorrect old password') })
const rect = oldPasswordRef.current.getBoundingClientRect()
const topPosition = window.scrollY + rect.top - DEFAULT_HEADER_OFFSET * 2
window.scrollTo({
top: topPosition,
left: 0,
behavior: 'smooth',
})
setIsFloatingPanelVisible(false)
}
return
}
showSnackbar({ type: 'success', body: t('Profile successfully saved') })
} catch (error) {
console.error(error)
} finally {
setIsSubmitting(false)
}
}
return ( return (
<PageLayout title={t('Profile')}> <PageLayout title={t('Profile')}>
<AuthGuard> <AuthGuard>
<div class="wide-container"> <Show when={isSessionLoaded()} fallback={<Loading />}>
<div class="row"> <div class="wide-container">
<div class="col-md-5"> <div class="row">
<div class={clsx('left-navigation', styles.leftNavigation)}> <div class="col-md-5">
<ProfileSettingsNavigation /> <div class={clsx('left-navigation', styles.leftNavigation)}>
<ProfileSettingsNavigation />
</div>
</div> </div>
</div>
<div class="col-md-19"> <div class="col-md-19">
<div class="row"> <div class="row">
<div class="col-md-20 col-lg-18 col-xl-16"> <div class="col-md-20 col-lg-18 col-xl-16">
<h1>Вход и&nbsp;безопасность</h1> <h1>{t('Login and security')}</h1>
<p class="description">Настройки аккаунта, почты, пароля и&nbsp;способов входа.</p> <p class="description">
{t('Settings for account, email, password and login methods.')}
<form>
<h4>Почта</h4>
<div class="pretty-form__item">
<input type="text" name="email" id="email" placeholder="Почта" />
<label for="email">Почта</label>
</div>
<h4>Изменить пароль</h4>
<h5>Текущий пароль</h5>
<div class="pretty-form__item">
<input
type="text"
name="password-current"
id="password-current"
class={clsx(styles.passwordInput, 'nolabel')}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-hide" />
</button>
</div>
<h5>Новый пароль</h5>
<div class="pretty-form__item">
<input
type="password"
name="password-new"
id="password-new"
class={clsx(styles.passwordInput, 'nolabel')}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-open" />
</button>
</div>
<h5>Подтвердите новый пароль</h5>
<div class="pretty-form__item">
<input
type="password"
name="password-new-confirm"
id="password-new-confirm"
class={clsx(styles.passwordInput, 'nolabel')}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-open" />
</button>
</div>
<h4>Социальные сети</h4>
<h5>Google</h5>
<div class="pretty-form__item">
<p>
<button class={clsx('button', 'button--light', styles.socialButton)} type="button">
<Icon name="google" class={styles.icon} />
Привязать
</button>
</p>
</div>
<h5>VK</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button', 'button--light')} type="button">
<Icon name="vk" class={styles.icon} />
Привязать
</button>
</p>
</div>
<h5>Facebook</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button', 'button--light')} type="button">
<Icon name="facebook" class={styles.icon} />
Привязать
</button>
</p>
</div>
<h5>Apple</h5>
<div class="pretty-form__item">
<p>
<button
class={clsx(
styles.socialButton,
styles.socialButtonApple,
'button' + ' button--light',
)}
type="button"
>
<Icon name="apple" class={styles.icon} />
Привязать
</button>
</p>
</div>
<br />
<p>
<button class="button button--submit" type="submit">
Сохранить настройки
</button>
</p> </p>
</form>
<form>
<h4>{t('Email')}</h4>
<div class="pretty-form__item">
<input
type="text"
name="email"
id="email"
disabled={isSubmitting()}
value={formData()['email'] || ''}
placeholder={t('Email')}
onFocus={() => setEmailError()}
onInput={(event) => handleChangeEmail(event.target.value)}
/>
<label for="email">{t('Email')}</label>
<Show when={emailError()}>
<div
class={clsx(styles.emailValidationError, {
'form-message--error': emailError(),
})}
>
{emailError()}
</div>
</Show>
</div>
<h4>{t('Change password')}</h4>
<h5>{t('Current password')}</h5>
<div ref={(el) => (oldPasswordRef.current = el)}>
<PasswordField
onFocus={() => setOldPasswordError()}
setError={oldPasswordError()}
onInput={(value) => handleInputChange('oldPassword', value)}
value={formData()['oldPassword'] ?? null}
disabled={isSubmitting()}
/>
</div>
<h5>{t('New password')}</h5>
<PasswordField
onInput={(value) => {
handleInputChange('newPassword', value)
handleInputChange('newPasswordConfirm', '')
}}
value={formData()['newPassword'] ?? ''}
disabled={isSubmitting()}
disableAutocomplete={true}
/>
<h5>{t('Confirm your new password')}</h5>
<div ref={(el) => (newPasswordRepeatRef.current = el)}>
<PasswordField
noValidate={true}
value={
formData()['newPasswordConfirm']?.length > 0
? formData()['newPasswordConfirm']
: null
}
onFocus={() => setNewPasswordError()}
setError={newPasswordError()}
onInput={(value) => handleCheckNewPassword(value)}
disabled={isSubmitting()}
disableAutocomplete={true}
/>
</div>
<h4>{t('Social networks')}</h4>
<h5>Google</h5>
<div class="pretty-form__item">
<p>
<button
class={clsx('button', 'button--light', styles.socialButton)}
type="button"
>
<Icon name="google" class={styles.icon} />
{t('Connect')}
</button>
</p>
</div>
<h5>VK</h5>
<div class="pretty-form__item">
<p>
<button
class={clsx(styles.socialButton, 'button', 'button--light')}
type="button"
>
<Icon name="vk" class={styles.icon} />
{t('Connect')}
</button>
</p>
</div>
<h5>Facebook</h5>
<div class="pretty-form__item">
<p>
<button
class={clsx(styles.socialButton, 'button', 'button--light')}
type="button"
>
<Icon name="facebook" class={styles.icon} />
{t('Connect')}
</button>
</p>
</div>
<h5>Apple</h5>
<div class="pretty-form__item">
<p>
<button
class={clsx(
styles.socialButton,
styles.socialButtonApple,
'button' + ' button--light',
)}
type="button"
>
<Icon name="apple" class={styles.icon} />
{t('Connect')}
</button>
</p>
</div>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </Show>
<Show when={isFloatingPanelVisible() && !emailError() && !newPasswordError()}>
<div class={styles.formActions}>
<div class="wide-container">
<div class="row">
<div class="col-md-19 offset-md-5">
<div class="row">
<div class="col-md-20 col-lg-18 col-xl-16">
<div class={styles.content}>
<Button
class={styles.cancel}
variant="light"
value={
<>
<span class={styles.cancelLabel}>{t('Cancel changes')}</span>
<span class={styles.cancelLabelMobile}>{t('Cancel')}</span>
</>
}
onClick={handleCancel}
/>
<Button
onClick={handleSubmit}
variant="primary"
disabled={isSubmitting()}
value={isSubmitting() ? t('Saving...') : t('Save settings')}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</AuthGuard> </AuthGuard>
</PageLayout> </PageLayout>
) )