diff --git a/package.json b/package.json index 289d1149..35efc4a1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "deploy": "graphql-codegen && npm run typecheck && vite build && vercel", "dev": "vite", "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", "hygen": "HYGEN_TMPLS=gen hygen", "postinstall": "npm run codegen && npx patch-package", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 064c8c5d..29fed7a8 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -91,6 +91,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": "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", @@ -417,6 +418,7 @@ "Username": "Username", "Userpic": "Userpic", "Users": "Users", + "User was not found": "User was not found", "Video format not supported": "Video format not supported", "Video": "Video", "Views": "Views", @@ -469,7 +471,6 @@ "cancel": "cancel", "collections": "collections", "community": "community", - "contents": "contents", "delimiter": "delimiter", "discussion": "Discours", "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", "Restore password": "Restore password", "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" } diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 09497c5a..91134e24 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -95,6 +95,7 @@ "Community Principles": "Принципы сообщества", "Community values and rules of engagement for the open editorial team": "Ценности сообщества и правила взаимодействия открытой редакции", "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": "Внесите вклад в свободный самиздат. Поддержите Дискурс — независимое некоммерческое издание, которое работает только для вас. Станьте опорой открытой редакции", "Cooperate": "Соучаствовать", "Copy link": "Скопировать ссылку", @@ -492,7 +493,6 @@ "cancel": "отменить", "collections": "коллекции", "community": "сообщество", - "contents": "оглавление", "create_chat": "Создать чат", "create_group": "Создать группу", "delimiter": "разделитель", @@ -549,6 +549,7 @@ "topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования", "topics": "темы", "user already exist": "пользователь уже существует", + "User was not found": "Пользователь не найден", "verified": "уже подтверждён", "video": "видео", "view": "просмотр", @@ -558,5 +559,13 @@ "It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля", "Restore password": "Восстановить пароль", "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": "Неверное подтверждение нового пароля" } diff --git a/src/components/Article/FullArticle.tsx b/src/components/Article/FullArticle.tsx index eb10ea09..3cea62c2 100644 --- a/src/components/Article/FullArticle.tsx +++ b/src/components/Article/FullArticle.tsx @@ -538,7 +538,7 @@ export const FullArticle = (props: Props) => { {(triggerRef: (el) => void) => (
diff --git a/src/components/Author/AuthorBadge/AuthorBadge.tsx b/src/components/Author/AuthorBadge/AuthorBadge.tsx index 665f300f..bcb29a29 100644 --- a/src/components/Author/AuthorBadge/AuthorBadge.tsx +++ b/src/components/Author/AuthorBadge/AuthorBadge.tsx @@ -54,7 +54,7 @@ export const AuthorBadge = (props: Props) => { requireAuthentication(() => { openPage(router, 'inbox') changeSearchParams({ - initChat: props.author.id.toString(), + initChat: props.author?.id.toString(), }) }, 'discussions') } diff --git a/src/components/Author/AuthorCard/AuthorCard.tsx b/src/components/Author/AuthorCard/AuthorCard.tsx index 53d764c5..1f0d3ac1 100644 --- a/src/components/Author/AuthorCard/AuthorCard.tsx +++ b/src/components/Author/AuthorCard/AuthorCard.tsx @@ -66,7 +66,7 @@ export const AuthorCard = (props: Props) => { requireAuthentication(() => { openPage(router, 'inbox') changeSearchParams({ - initChat: props.author.id.toString(), + initChat: props.author?.id.toString(), }) }, 'discussions') } diff --git a/src/components/Draft/Draft.tsx b/src/components/Draft/Draft.tsx index cfbc9b0c..b66b9c1a 100644 --- a/src/components/Draft/Draft.tsx +++ b/src/components/Draft/Draft.tsx @@ -60,7 +60,7 @@ export const Draft = (props: Props) => {
{t('Edit')} diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx index 187fe25f..18e8792c 100644 --- a/src/components/Editor/SimplifiedEditor.tsx +++ b/src/components/Editor/SimplifiedEditor.tsx @@ -48,11 +48,13 @@ type Props = { onChange?: (text: string) => void variant?: 'minimal' | 'bordered' maxLength?: number + noLimits?: boolean maxHeight?: number submitButtonText?: string quoteEnabled?: boolean imageEnabled?: boolean setClear?: boolean + resetToInitial?: boolean smallHeight?: boolean submitByCtrlEnter?: boolean onlyBubbleControls?: boolean @@ -124,7 +126,7 @@ const SimplifiedEditor = (props: Props) => { openOnClick: false, }), CharacterCount.configure({ - limit: maxLength, + limit: props.noLimits ? null : maxLength, }), Blockquote.configure({ HTMLAttributes: { @@ -216,6 +218,10 @@ const SimplifiedEditor = (props: Props) => { if (props.setClear) { editor().commands.clearContent(true) } + if (props.resetToInitial) { + editor().commands.clearContent(true) + editor().commands.setContent(props.initialContent) + } }) const handleKeyDown = (event) => { diff --git a/src/components/Feed/ArticleCard/ArticleCard.tsx b/src/components/Feed/ArticleCard/ArticleCard.tsx index 19529050..9fad27b5 100644 --- a/src/components/Feed/ArticleCard/ArticleCard.tsx +++ b/src/components/Feed/ArticleCard/ArticleCard.tsx @@ -328,7 +328,7 @@ export const ArticleCard = (props: ArticleCardProps) => { {(triggerRef: (el) => void) => (
- + { try { const { errors } = await signIn({ email: email(), password: password() }) - console.error('[signIn errors]', errors) if (errors?.length > 0) { - if ( - errors.some( - (error) => - error.message.includes('bad user credentials') || error.message.includes('user not found'), - ) - ) { - setValidationErrors((prev) => ({ - ...prev, - password: t('Something went wrong, check email and password'), - })) - } else if (errors.some((error) => error.message.includes('user not found'))) { - setSubmitError('Пользователь не найден') - } else if (errors.some((error) => error.message.includes('email not verified'))) { - setSubmitError( -
- {t('This email is not verified')} - {'. '} - - {t('Send link again')} - -
, - ) - } else { - setSubmitError(t('Error', errors[0].message)) - } + console.warn('[signIn] errors: ', errors) + errors.forEach((error) => { + switch (error.message) { + case 'user has not signed up email & password': { + setValidationErrors((prev) => ({ + ...prev, + password: t('Something went wrong, check email and password'), + })) + break + } + case 'user not found': { + setValidationErrors((prev) => ({ ...prev, email: t('User was not found') })) + break + } + case 'email not verified': { + setValidationErrors((prev) => ({ ...prev, email: t('This email is not verified') })) + break + } + default: + setSubmitError( +
+ {t('Error', errors[0].message)} + {'. '} + + {t('Send link again')} + +
, + ) + } + }) return } hideModal() diff --git a/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx b/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx index a478225d..a2ec9185 100644 --- a/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx +++ b/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx @@ -16,6 +16,9 @@ type Props = { onBlur?: (value: string) => void variant?: 'login' | 'registration' disableAutocomplete?: boolean + noValidate?: boolean + onFocus?: () => void + value?: string } const minLength = 8 @@ -27,7 +30,7 @@ export const PasswordField = (props: Props) => { const [showPassword, setShowPassword] = createSignal(false) const [error, setError] = createSignal() - const validatePassword = (passwordToCheck) => { + const validatePassword = (passwordToCheck: string) => { if (passwordToCheck.length < minLength) { return t('Password should be at least 8 characters') } @@ -50,11 +53,13 @@ export const PasswordField = (props: Props) => { } props.onInput(value) - const errorValue = validatePassword(value) - if (errorValue) { - setError(errorValue) - } else { - setError() + if (!props.noValidate) { + const errorValue = validatePassword(value) + if (errorValue) { + setError(errorValue) + } else { + setError() + } } } @@ -78,6 +83,8 @@ export const PasswordField = (props: Props) => { id="password" name="password" disabled={props.disabled} + onFocus={props.onFocus} + value={props.value ? props.value : ''} autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'} type={showPassword() ? 'text' : 'password'} placeholder={props.placeholder || t('Password')} diff --git a/src/components/Nav/HeaderAuth.tsx b/src/components/Nav/HeaderAuth.tsx index d3e38709..57a978ca 100644 --- a/src/components/Nav/HeaderAuth.tsx +++ b/src/components/Nav/HeaderAuth.tsx @@ -103,7 +103,13 @@ export const HeaderAuth = (props: Props) => {
-
+
{t('Create post')} @@ -210,11 +216,17 @@ export const HeaderAuth = (props: Props) => { -
+ @@ -227,7 +239,7 @@ export const HeaderAuth = (props: Props) => { {t('Enter')} - {/**/} +
diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx index 15532ddb..a74597a5 100644 --- a/src/components/ProfileSettings/ProfileSettings.tsx +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -20,6 +20,8 @@ import { useLocalize } from '../../context/localize' import { useProfileForm } from '../../context/profile' import { useSession } from '../../context/session' 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 { clone } from '../../utils/clone' import { getImageUrl } from '../../utils/getImageUrl' @@ -35,14 +37,12 @@ import { Loading } from '../_shared/Loading' import { Popover } from '../_shared/Popover' import { SocialNetworkInput } from '../_shared/SocialNetworkInput' -import styles from '../../pages/profile/Settings.module.scss' - const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea')) export const ProfileSettings = () => { const { t } = useLocalize() - const [prevForm, setPrevForm] = createStore({}) + const [prevForm, setPrevForm] = createStore({}) const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [isSaving, setIsSaving] = createSignal(false) const [social, setSocial] = createSignal([]) @@ -59,6 +59,7 @@ export const ProfileSettings = () => { const { showSnackbar } = useSnackbar() const { loadAuthor, session } = useSession() const { showConfirm } = useConfirm() + const [clearAbout, setClearAbout] = createSignal(false) createEffect(() => { if (Object.keys(form).length > 0 && !isFormInitialized()) { @@ -121,7 +122,9 @@ export const ProfileSettings = () => { declineButtonVariant: 'secondary', }) if (isConfirmed) { + setClearAbout(true) setForm(clone(prevForm)) + setClearAbout(false) } } @@ -171,11 +174,13 @@ export const ProfileSettings = () => { on( () => deepEqual(form, prevForm), () => { - setIsFloatingPanelVisible(!deepEqual(form, prevForm)) + if (Object.keys(prevForm).length > 0) { + setIsFloatingPanelVisible(!deepEqual(form, prevForm)) + } }, - { defer: true }, ), ) + const handleDeleteSocialLink = (link) => { updateFormField('links', link, true) } @@ -317,6 +322,8 @@ export const ProfileSettings = () => {

{t('About')}

{
-

{t('contents')}

+

{t('Contents')}

    diff --git a/src/components/Topic/TopicBadge/TopicBadge.module.scss b/src/components/Topic/TopicBadge/TopicBadge.module.scss index 52edb734..6b818c60 100644 --- a/src/components/Topic/TopicBadge/TopicBadge.module.scss +++ b/src/components/Topic/TopicBadge/TopicBadge.module.scss @@ -45,7 +45,6 @@ .info { @include font-size(1.4rem); - border: none; // display: flex; @@ -63,13 +62,11 @@ .title { @include font-size(2.2rem); - font-weight: bold; } .description { @include font-size(1.6rem); - line-height: 1.4; margin: 0.8rem 0; -webkit-line-clamp: 2; @@ -107,7 +104,6 @@ .title { @include font-size(1.4rem); - font-weight: 500; line-height: 1em; color: var(--blue-500); @@ -116,9 +112,7 @@ .description { color: var(--black-400); - @include font-size(1.2rem); - font-weight: 500; margin: 0; } diff --git a/src/components/Topic/TopicBadge/TopicBadge.tsx b/src/components/Topic/TopicBadge/TopicBadge.tsx index fa7e577b..73afe366 100644 --- a/src/components/Topic/TopicBadge/TopicBadge.tsx +++ b/src/components/Topic/TopicBadge/TopicBadge.tsx @@ -48,7 +48,7 @@ export const TopicBadge = (props: Props) => { lang() === 'en' ? capitalize(props.topic.slug.replaceAll('-', ' ')) : props.topic.title return ( -
    +
    diff --git a/src/components/Views/Author/Author.tsx b/src/components/Views/Author/Author.tsx index 3d102df9..9c0423ba 100644 --- a/src/components/Views/Author/Author.tsx +++ b/src/components/Views/Author/Author.tsx @@ -3,10 +3,10 @@ import type { Author, Reaction, Shout, Topic } from '../../../graphql/schema/cor import { getPagePath } from '@nanostores/router' import { clsx } from 'clsx' 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 { useLocalize } from '../../../context/localize' +import { Meta, Title } from '../../../context/meta' import { useSession } from '../../../context/session' import { apiClient } from '../../../graphql/client/core' import { router, useRouter } from '../../../stores/router' @@ -76,24 +76,17 @@ export const AuthorView = (props: Props) => { const fetchData = async (slug: string) => { try { - const [followsResult, followersResult, authorResult] = await Promise.all([ + const [subscriptionsResult, followersResult, authorResult] = await Promise.all([ apiClient.getAuthorFollows({ slug }), apiClient.getAuthorFollowers({ slug }), loadAuthor({ slug }), ]) - const { authors, topics, error } = followsResult - if (error) { - console.error(error) - return - } - console.debug(authorResult) + const { authors, topics } = subscriptionsResult setAuthor(authorResult) - console.debug(authors, topics) setFollowing([...(authors || []), ...(topics || [])]) - console.debug(followersResult) setFollowers(followersResult || []) - console.debug('[components.Author] author follows:', followsResult) + console.info('[components.Author] data loaded') } catch (error) { console.error('[components.Author] fetch error', error) } @@ -250,7 +243,7 @@ export const AuthorView = (props: Props) => { class={styles.longBio} classList={{ [styles.longBioExpanded]: isBioExpanded() }} > -
    (bioContainerRef.current = el)} innerHTML={author().about} /> +
    (bioContainerRef.current = el)} innerHTML={author()?.about || ''} />
    diff --git a/src/components/Views/EditView/EditView.tsx b/src/components/Views/EditView/EditView.tsx index 1e8650b1..2148a3e4 100644 --- a/src/components/Views/EditView/EditView.tsx +++ b/src/components/Views/EditView/EditView.tsx @@ -2,7 +2,7 @@ import { clsx } from 'clsx' import deepEqual from 'fast-deep-equal' import { Accessor, Show, createMemo, createSignal, lazy, onCleanup, onMount } from 'solid-js' import { createStore } from 'solid-js/store' -import { throttle } from 'throttle-debounce' +import { debounce } from 'throttle-debounce' import { ShoutForm, useEditorContext } from '../../../context/editor' import { useLocalize } from '../../../context/localize' @@ -42,9 +42,8 @@ export const EMPTY_TOPIC: Topic = { slug: '', } -const THROTTLING_INTERVAL = 2000 -const AUTO_SAVE_INTERVAL = 5000 -const AUTO_SAVE_DELAY = 5000 +const AUTO_SAVE_DELAY = 3000 + const handleScrollTopButtonClick = (e) => { e.preventDefault() window.scrollTo({ @@ -104,6 +103,8 @@ export const EditView = (props: Props) => { return JSON.parse(form.media || '[]') }) + const [hasChanges, setHasChanges] = createSignal(false) + onMount(() => { const handleScroll = () => { setIsScrolled(window.scrollY > 0) @@ -113,7 +114,7 @@ export const EditView = (props: Props) => { onCleanup(() => { window.removeEventListener('scroll', handleScroll) }) - // eslint-disable-next-line unicorn/consistent-function-scoping + const handleBeforeUnload = (event) => { if (!deepEqual(prevForm, form)) { event.returnValue = t( @@ -127,8 +128,8 @@ export const EditView = (props: Props) => { }) const handleTitleInputChange = (value: string) => { - setForm('title', value) - setForm('slug', slugify(value)) + handleInputChange('title', value) + handleInputChange('slug', slugify(value)) if (value) { setFormErrors('title', '') } @@ -136,21 +137,21 @@ export const EditView = (props: Props) => { const handleAddMedia = (data) => { const newMedia = [...mediaItems(), ...data] - setForm('media', JSON.stringify(newMedia)) + handleInputChange('media', JSON.stringify(newMedia)) } const handleSortedMedia = (data) => { - setForm('media', JSON.stringify(data)) + handleInputChange('media', JSON.stringify(data)) } const handleMediaDelete = (index) => { const copy = [...mediaItems()] copy.splice(index, 1) - setForm('media', JSON.stringify(copy)) + handleInputChange('media', JSON.stringify(copy)) } const handleMediaChange = (index, value) => { const updated = mediaItems().map((item, idx) => (idx === index ? value : item)) - setForm('media', JSON.stringify(updated)) + handleInputChange('media', JSON.stringify(updated)) } const [baseAudioFields, setBaseAudioFields] = createSignal({ @@ -162,7 +163,7 @@ export const EditView = (props: Props) => { const handleBaseFieldsChange = (key, value) => { if (mediaItems().length > 0) { const updated = mediaItems().map((media) => ({ ...media, [key]: value })) - setForm('media', JSON.stringify(updated)) + handleInputChange('media', JSON.stringify(updated)) } else { setBaseAudioFields({ ...baseAudioFields(), [key]: value }) } @@ -182,34 +183,32 @@ export const EditView = (props: Props) => { } } - let autoSaveTimeOutId: number | string | NodeJS.Timeout - const autoSave = async () => { - const hasChanges = !deepEqual(form, prevForm) - const hasTopic = Boolean(form.mainTopic) - if (hasChanges || hasTopic) { + console.log('autoSave called') + if (hasChanges()) { console.debug('saving draft', form) setSaving(true) saveDraftToLocalStorage(form) await saveDraft(form) setPrevForm(clone(form)) - setTimeout(() => setSaving(false), AUTO_SAVE_DELAY) + setSaving(false) + setHasChanges(false) } } - // Throttle the autoSave function - const throttledAutoSave = throttle(THROTTLING_INTERVAL, autoSave) + const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, autoSave) - const autoSaveRecursive = () => { - autoSaveTimeOutId = setTimeout(() => { - throttledAutoSave() - autoSaveRecursive() - }, AUTO_SAVE_INTERVAL) + const handleInputChange = (key, value) => { + console.log(`[handleInputChange] ${key}: ${value}`) + setForm(key, value) + setHasChanges(true) + debouncedAutoSave() } onMount(() => { - autoSaveRecursive() - onCleanup(() => clearTimeout(autoSaveTimeOutId)) + onCleanup(() => { + debouncedAutoSave.cancel() + }) }) const showSubtitleInput = () => { @@ -310,7 +309,7 @@ export const EditView = (props: Props) => { subtitleInput.current = el }} allowEnterKey={false} - value={(value) => setForm('subtitle', value || '')} + value={(value) => handleInputChange('subtitle', value || '')} class={styles.subtitleInput} placeholder={t('Subheader')} initialValue={form.subtitle || ''} @@ -324,7 +323,7 @@ export const EditView = (props: Props) => { smallHeight={true} placeholder={t('A short introduction to keep the reader interested')} initialContent={form.lead} - onChange={(value) => setForm('lead', value)} + onChange={(value) => handleInputChange('lead', value)} /> @@ -345,7 +344,7 @@ export const EditView = (props: Props) => { } isMultiply={false} 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) => {
    setForm('coverImageUrl', null)} + onClick={() => handleInputChange('coverImageUrl', null)} >
    @@ -408,7 +407,7 @@ export const EditView = (props: Props) => { setForm('body', body)} + onChange={(body) => handleInputChange('body', body)} />
    diff --git a/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx b/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx index 067748d5..c614cba1 100644 --- a/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx +++ b/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx @@ -37,7 +37,6 @@ export const ArticleCardSwiper = (props: Props) => { [styles.Swiper]: props.slides.length > 1, [styles.articleMode]: true, [styles.ArticleCardSwiper]: props.slides.length > 1, - [styles.unswiped]: props.slides.length === 1, })} > diff --git a/src/context/connect.tsx b/src/context/connect.tsx index dfd8d549..f8ebb1c9 100644 --- a/src/context/connect.tsx +++ b/src/context/connect.tsx @@ -30,53 +30,57 @@ const ConnectContext = createContext() export const ConnectProvider = (props: { children: JSX.Element }) => { const [messageHandlers, setHandlers] = createSignal([]) - // const [messages, setMessages] = createSignal>([]); const [connected, setConnected] = createSignal(false) const { session } = useSession() + const [retried, setRetried] = createSignal(0) const addHandler = (handler: MessageHandler) => { setHandlers((hhh) => [...hhh, handler]) } - const [retried, setRetried] = createSignal(0) createEffect(async () => { const token = session()?.access_token - if (token && !connected()) { + if (token && !connected() && retried() <= RECONNECT_TIMES) { console.info('[context.connect] init SSE connection') - await fetchEventSource('https://connect.discours.io', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Authorization: token, - }, - onmessage(event) { - 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)) - }, - async onopen(response) { - console.log('[context.connect] SSE connection opened', response) - if (response.ok && response.headers.get('content-type') === EventStreamContentType) { - setConnected(true) - } else if (response.status === 401) { - throw new Error('unauthorized') - } else { - setRetried((r) => r + 1) - throw new Error('Internal Error') - } - }, - onclose() { - console.log('[context.connect] SSE connection closed by server') - setConnected(false) - }, - onerror(err) { - if (err.message === 'unauthorized' || retried() > RECONNECT_TIMES) { - throw err // rethrow to stop the operation - } - }, - }) + try { + await fetchEventSource('https://connect.discours.io', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + onmessage(event) { + const m: SSEMessage = JSON.parse(event.data || '{}') + console.log('[context.connect] Received message:', m) + messageHandlers().forEach((handler) => handler(m)) + }, + onopen: (response) => { + console.log('[context.connect] SSE connection opened', response) + if (response.ok && response.headers.get('content-type') === EventStreamContentType) { + setConnected(true) + setRetried(0) + return Promise.resolve() + } + return Promise.reject(`SSE: cannot connect to real-time updates, status: ${response.status}`) + }, + onclose() { + console.log('[context.connect] SSE connection closed by server') + setConnected(false) + if (retried() < RECONNECT_TIMES) { + setRetried((r) => r + 1) + } + }, + onerror(err) { + console.error('[context.connect] SSE connection error:', err) + setConnected(false) + if (retried() < RECONNECT_TIMES) { + setRetried((r) => r + 1) + } else throw Error(err) + }, + }) + } catch (error) { + console.error('[context.connect] SSE connection failed:', error) + } } }) diff --git a/src/context/session.tsx b/src/context/session.tsx index 8ebb803a..27ed21e5 100644 --- a/src/context/session.tsx +++ b/src/context/session.tsx @@ -13,6 +13,7 @@ import { LoginInput, ResendVerifyEmailInput, SignupInput, + UpdateProfileInput, VerifyEmailInput, } from '@authorizerdev/authorizer-js' import { @@ -58,6 +59,7 @@ export type SessionContextType = { ) => void signUp: (params: SignupInput) => Promise<{ data: AuthToken; errors: Error[] }> signIn: (params: LoginInput) => Promise<{ data: AuthToken; errors: Error[] }> + updateProfile: (params: UpdateProfileInput) => Promise<{ data: AuthToken; errors: Error[] }> signOut: () => Promise oauth: (provider: string) => Promise forgotPassword: ( @@ -223,9 +225,12 @@ export const SessionProvider = (props: { const appdata = session()?.user.app_data if (appdata) { const { profile } = appdata - setAuthor(profile) - addAuthors([profile]) - if (!profile) loadAuthor() + if (profile?.id) { + setAuthor(profile) + addAuthors([profile]) + } else { + setTimeout(loadAuthor, 15) + } } } catch (e) { console.error(e) @@ -305,6 +310,8 @@ export const SessionProvider = (props: { } const signUp = async (params: SignupInput) => await authenticate(authorizer().signup, 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 authResult: ApiResponse = await authorizer().logout() @@ -381,6 +388,7 @@ export const SessionProvider = (props: { signIn, signOut, confirmEmail, + updateProfile, setIsSessionLoaded, setSession, setAuthor, diff --git a/src/pages/create.page.tsx b/src/pages/create.page.tsx index 833d328e..e5b7e8a1 100644 --- a/src/pages/create.page.tsx +++ b/src/pages/create.page.tsx @@ -18,7 +18,7 @@ import styles from '../styles/Create.module.scss' const handleCreate = async (layout: LayoutType) => { const shout = await apiClient.createArticle({ article: { layout: layout } }) redirectPage(router, 'edit', { - shoutId: shout.id.toString(), + shoutId: shout?.id.toString(), }) } diff --git a/src/pages/edit.page.tsx b/src/pages/edit.page.tsx index 21353681..aaa08eee 100644 --- a/src/pages/edit.page.tsx +++ b/src/pages/edit.page.tsx @@ -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 { Loading } from '../components/_shared/Loading' @@ -7,7 +7,7 @@ import { useLocalize } from '../context/localize' import { useSession } from '../context/session' import { apiClient } from '../graphql/client/core' import { Shout } from '../graphql/schema/core.gen' -import { router } from '../stores/router' +import { router, useRouter } from '../stores/router' import { redirectPage } from '@nanostores/router' import { useSnackbar } from '../context/snackbar' @@ -33,6 +33,7 @@ const getContentTypeTitle = (layout: LayoutType) => { export const EditPage = () => { const { t } = useLocalize() const { session } = useSession() + const { page } = useRouter() const snackbar = useSnackbar() const fail = async (error: string) => { @@ -45,12 +46,22 @@ export const EditPage = () => { const [shoutId, setShoutId] = createSignal(0) const [shout, setShout] = createSignal() - onMount(() => { - const shoutId = window.location.pathname.split('/').pop() - const shoutIdFromUrl = Number.parseInt(shoutId ?? '0', 10) - console.debug(`editing shout ${shoutIdFromUrl}`) - if (shoutIdFromUrl) setShoutId(shoutIdFromUrl) - }) + createEffect( + on( + () => page(), + (p) => { + 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( on([session, shout, shoutId], async ([ses, sh, shid]) => { @@ -63,6 +74,7 @@ export const EditPage = () => { } } }), + { defer: true }, ) const title = createMemo(() => { diff --git a/src/pages/profile/Settings.module.scss b/src/pages/profile/Settings.module.scss index 9bd4906c..38babb4d 100644 --- a/src/pages/profile/Settings.module.scss +++ b/src/pages/profile/Settings.module.scss @@ -100,17 +100,6 @@ h5 { } } -.passwordToggleControl { - position: absolute; - right: 1em; - transform: translateY(-50%); - top: 50%; -} - -.passwordInput { - padding-right: 3em !important; -} - .searchContainer { margin-top: 2.4rem; } @@ -331,3 +320,12 @@ div[data-lastpass-infield="true"] { opacity: 0 !important; } + +.emailValidationError { + position: absolute; + top: 100%; + font-size: 12px; + line-height: 16px; + margin-top: 0.3em; + color: var(--danger-color); +} diff --git a/src/pages/profile/profileSecurity.page.tsx b/src/pages/profile/profileSecurity.page.tsx index 2e572047..b19f139e 100644 --- a/src/pages/profile/profileSecurity.page.tsx +++ b/src/pages/profile/profileSecurity.page.tsx @@ -6,135 +6,321 @@ import { Icon } from '../../components/_shared/Icon' import { PageLayout } from '../../components/_shared/PageLayout' 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' +type FormField = 'oldPassword' | 'newPassword' | 'newPasswordConfirm' | 'email' export const ProfileSecurityPage = () => { const { t } = useLocalize() + const { updateProfile, session, isSessionLoaded } = useSession() + const { showSnackbar } = useSnackbar() + const { showConfirm } = useConfirm() + + const [newPasswordError, setNewPasswordError] = createSignal() + const [oldPasswordError, setOldPasswordError] = createSignal() + const [emailError, setEmailError] = createSignal() + const [isSubmitting, setIsSubmitting] = createSignal() + 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 ( -
    -
    -
    -
    - + }> +
    +
    +
    +
    + +
    -
    -
    -
    -
    -

    Вход и безопасность

    -

    Настройки аккаунта, почты, пароля и способов входа.

    - -
    -

    Почта

    -
    - - -
    - -

    Изменить пароль

    -
    Текущий пароль
    -
    - - -
    - -
    Новый пароль
    -
    - - -
    - -
    Подтвердите новый пароль
    -
    - - -
    - -

    Социальные сети

    -
    Google
    -
    -

    - -

    -
    - -
    VK
    -
    -

    - -

    -
    - -
    Facebook
    -
    -

    - -

    -
    - -
    Apple
    -
    -

    - -

    -
    - -
    -

    - +

    +
    +
    +

    {t('Login and security')}

    +

    + {t('Settings for account, email, password and login methods.')}

    - + +
    +

    {t('Email')}

    +
    + setEmailError()} + onInput={(event) => handleChangeEmail(event.target.value)} + /> + + +
    + {emailError()} +
    +
    +
    + +

    {t('Change password')}

    +
    {t('Current password')}
    + +
    (oldPasswordRef.current = el)}> + setOldPasswordError()} + setError={oldPasswordError()} + onInput={(value) => handleInputChange('oldPassword', value)} + value={formData()['oldPassword'] ?? null} + disabled={isSubmitting()} + /> +
    + +
    {t('New password')}
    + { + handleInputChange('newPassword', value) + handleInputChange('newPasswordConfirm', '') + }} + value={formData()['newPassword'] ?? ''} + disabled={isSubmitting()} + disableAutocomplete={true} + /> + +
    {t('Confirm your new password')}
    +
    (newPasswordRepeatRef.current = el)}> + 0 + ? formData()['newPasswordConfirm'] + : null + } + onFocus={() => setNewPasswordError()} + setError={newPasswordError()} + onInput={(value) => handleCheckNewPassword(value)} + disabled={isSubmitting()} + disableAutocomplete={true} + /> +
    +

    {t('Social networks')}

    +
    Google
    +
    +

    + +

    +
    + +
    VK
    +
    +

    + +

    +
    + +
    Facebook
    +
    +

    + +

    +
    + +
    Apple
    +
    +

    + +

    +
    + +
    -
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    )