diff --git a/public/icons/expert.svg b/public/icons/expert.svg
new file mode 100644
index 00000000..e81cb43e
--- /dev/null
+++ b/public/icons/expert.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index f8b6760e..34d67159 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -93,6 +93,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",
@@ -432,6 +433,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",
@@ -484,7 +486,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",
diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json
index 65a38874..2987f1f4 100644
--- a/public/locales/ru/translation.json
+++ b/public/locales/ru/translation.json
@@ -97,6 +97,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": "Скопировать ссылку",
@@ -507,7 +508,6 @@
"cancel": "отменить",
"collections": "коллекции",
"community": "сообщество",
- "contents": "оглавление",
"create_chat": "Создать чат",
"create_group": "Создать группу",
"delimiter": "разделитель",
@@ -564,6 +564,7 @@
"topicKeywords": "{topic}, Discours.io, статьи, журналистика, исследования",
"topics": "темы",
"user already exist": "пользователь уже существует",
+ "User was not found": "Пользователь не найден",
"verified": "уже подтверждён",
"video": "видео",
"view": "просмотр",
diff --git a/src/components/Author/AuthorCard/AuthorCard.module.scss b/src/components/Author/AuthorCard/AuthorCard.module.scss
index 14db38aa..a8e7e0e9 100644
--- a/src/components/Author/AuthorCard/AuthorCard.module.scss
+++ b/src/components/Author/AuthorCard/AuthorCard.module.scss
@@ -18,9 +18,8 @@
.authorName {
@include font-size(4rem);
-
font-weight: 700;
- margin-bottom: 0.2em;
+ margin-bottom: 1.2rem;
}
.authorAbout {
@@ -432,3 +431,16 @@
.listWrapper {
max-height: 70vh;
}
+
+.subscribersContainer {
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 1.4rem;
+ gap: 1rem;
+ margin-top: 0;
+ white-space: nowrap;
+
+ @include media-breakpoint-down(md) {
+ justify-content: center;
+ }
+}
diff --git a/src/components/Author/AuthorCard/AuthorCard.tsx b/src/components/Author/AuthorCard/AuthorCard.tsx
index 6a75dab0..8ddbba52 100644
--- a/src/components/Author/AuthorCard/AuthorCard.tsx
+++ b/src/components/Author/AuthorCard/AuthorCard.tsx
@@ -127,12 +127,14 @@ export const AuthorCard = (props: Props) => {
0 || props.following?.length > 0}>
-
+
+
+
diff --git a/src/components/Feed/FeedArticlePopup/FeedArticlePopup.module.scss b/src/components/Feed/FeedArticlePopup/FeedArticlePopup.module.scss
index d9141883..6b2d15d6 100644
--- a/src/components/Feed/FeedArticlePopup/FeedArticlePopup.module.scss
+++ b/src/components/Feed/FeedArticlePopup/FeedArticlePopup.module.scss
@@ -16,14 +16,15 @@
}
.action {
- display: flex;
align-items: center;
- width: 100%;
box-sizing: border-box;
- padding: 8px 16px;
+ display: flex;
font-size: inherit;
font-weight: 500;
+ gap: 0.8rem;
+ padding: 8px 16px;
text-align: left;
+ width: 100%;
white-space: nowrap;
&.soon {
@@ -32,11 +33,29 @@
gap: 0.6rem;
width: 100%;
justify-content: space-between;
+
+ .icon {
+ opacity: 0.4;
+ }
}
&:hover {
background: var(--black-500);
color: var(--black-50) !important;
+
+ .icon {
+ filter: invert(1);
+ opacity: 1 !important;
+ }
+ }
+
+ .icon {
+ flex: 0 2.4rem;
+ min-width: 2.4rem;
+ }
+
+ .title {
+ flex: 1;
}
}
diff --git a/src/components/Feed/FeedArticlePopup/FeedArticlePopup.tsx b/src/components/Feed/FeedArticlePopup/FeedArticlePopup.tsx
index ce9990bb..0943b814 100644
--- a/src/components/Feed/FeedArticlePopup/FeedArticlePopup.tsx
+++ b/src/components/Feed/FeedArticlePopup/FeedArticlePopup.tsx
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
import { Show, createSignal } from 'solid-js'
import { useLocalize } from '../../../context/localize'
+import { Icon } from '../../_shared/Icon'
import { Popup } from '../../_shared/Popup'
import { SoonChip } from '../../_shared/SoonChip'
@@ -38,7 +39,8 @@ export const FeedArticlePopup = (props: Props) => {
setHidePopup(true)
}}
>
- {t('Share')}
+
+ {t('Share')}
@@ -51,7 +53,8 @@ export const FeedArticlePopup = (props: Props) => {
setHidePopup(true)
}}
>
- {t('Help to edit')}
+
+ {t('Help to edit')}
@@ -64,19 +67,24 @@ export const FeedArticlePopup = (props: Props) => {
setHidePopup(false)
}}
>
- {t('Invite experts')}
+
+ {t('Invite experts')}
{/**/}
diff --git a/src/components/Nav/AuthModal/LoginForm.tsx b/src/components/Nav/AuthModal/LoginForm.tsx
index c7a26d68..925c8864 100644
--- a/src/components/Nav/AuthModal/LoginForm.tsx
+++ b/src/components/Nav/AuthModal/LoginForm.tsx
@@ -95,33 +95,37 @@ export const LoginForm = () => {
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/TableOfContents/TableOfContents.module.scss b/src/components/TableOfContents/TableOfContents.module.scss
index 2e5fe4ac..8433e44d 100644
--- a/src/components/TableOfContents/TableOfContents.module.scss
+++ b/src/components/TableOfContents/TableOfContents.module.scss
@@ -157,7 +157,7 @@
color: #000;
font-size: 14px;
font-style: normal;
- font-weight: 400;
+ font-weight: 500;
line-height: 1.8rem;
text-align: left;
vertical-align: bottom;
diff --git a/src/components/TableOfContents/TableOfContents.tsx b/src/components/TableOfContents/TableOfContents.tsx
index 322aa925..d0a2de05 100644
--- a/src/components/TableOfContents/TableOfContents.tsx
+++ b/src/components/TableOfContents/TableOfContents.tsx
@@ -86,7 +86,7 @@ export const TableOfContents = (props: Props) => {
diff --git a/src/components/Topic/Full.module.scss b/src/components/Topic/Full.module.scss
index ee8ba0bd..c34ea44e 100644
--- a/src/components/Topic/Full.module.scss
+++ b/src/components/Topic/Full.module.scss
@@ -44,18 +44,22 @@
}
.topicDetails {
+ align-items: flex-start;
display: flex;
flex-wrap: wrap;
font-size: 1.4rem;
justify-content: center;
- gap: 4rem;
+ gap: 1rem;
margin-top: 1.5rem;
}
.topicDetailsItem {
+ align-items: center;
display: flex;
+ margin-right: 1rem;
+ white-space: nowrap;
}
.topicDetailsIcon {
display: block;
-}
\ No newline at end of file
+}
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/Views/Topic.tsx b/src/components/Views/Topic.tsx
index 8579a739..7389b3a5 100644
--- a/src/components/Views/Topic.tsx
+++ b/src/components/Views/Topic.tsx
@@ -1,11 +1,4 @@
-import {
- Author,
- AuthorsBy,
- LoadShoutsOptions,
- QueryLoad_Authors_ByArgs,
- Shout,
- Topic,
-} from '../../graphql/schema/core.gen'
+import { Author, AuthorsBy, LoadShoutsOptions, Shout, Topic } from '../../graphql/schema/core.gen'
import { clsx } from 'clsx'
import { For, Show, createEffect, createMemo, createSignal, on, onMount } from 'solid-js'
diff --git a/src/components/_shared/Subscribers/Subscribers.module.scss b/src/components/_shared/Subscribers/Subscribers.module.scss
index 6248311c..84494bd3 100644
--- a/src/components/_shared/Subscribers/Subscribers.module.scss
+++ b/src/components/_shared/Subscribers/Subscribers.module.scss
@@ -1,19 +1,8 @@
-.subscribersContainer {
- display: flex;
- flex-wrap: wrap;
- font-size: 1.4rem;
- margin-top: 1.5rem;
-
- @include media-breakpoint-down(md) {
- justify-content: center;
- }
-}
-
.subscribers {
align-items: center;
cursor: pointer;
display: inline-flex;
- margin: 0 2% 1rem;
+ margin: 0 1rem 0 0;
vertical-align: top;
border-bottom: unset !important;
@@ -44,7 +33,6 @@
.subscribersCounter {
font-weight: 500;
- margin-left: 1rem;
}
&:hover {
@@ -55,3 +43,8 @@
}
}
}
+
+.subscribersList {
+ display: flex;
+ margin-right: 0.6rem;
+}
diff --git a/src/components/_shared/Subscribers/Subscribers.tsx b/src/components/_shared/Subscribers/Subscribers.tsx
index 97a5afc7..899f5565 100644
--- a/src/components/_shared/Subscribers/Subscribers.tsx
+++ b/src/components/_shared/Subscribers/Subscribers.tsx
@@ -18,12 +18,14 @@ export const Subscribers = (props: Props) => {
const { t } = useLocalize()
return (
-
+ <>
0}>
-
- {(f) => }
-
+
+
+ {(f) => }
+
+
+ >
)
}
diff --git a/src/context/connect.tsx b/src/context/connect.tsx
index 2b2dc808..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('SSE: cannot connect to real-time updates')
- } else {
- setRetried((r) => r + 1)
- throw new Error(`SSE: failed to connect ${retried()} times`)
- }
- },
- 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/pages/edit.page.tsx b/src/pages/edit.page.tsx
index f729e50b..15dc7c1c 100644
--- a/src/pages/edit.page.tsx
+++ b/src/pages/edit.page.tsx
@@ -70,6 +70,7 @@ export const EditPage = () => {
}
}
}),
+ { defer: true },
)
const title = createMemo(() => {
diff --git a/src/pages/topic.page.tsx b/src/pages/topic.page.tsx
index 4fa3fb49..f139081f 100644
--- a/src/pages/topic.page.tsx
+++ b/src/pages/topic.page.tsx
@@ -54,7 +54,7 @@ export const TopicPage = (props: PageProps) => {
const usePrerenderedData = props.topic?.slug === slug()
return (
-
+
}>