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..c0fcccd9 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": "Скопировать ссылку", @@ -297,9 +298,9 @@ "Paste Embed code": "Вставьте embed код", "Personal": "Личные", "Pin": "Закрепить", - "Placeholder feed": "Подпишитесь на любимые темы, авторов и сообщества — моментально узнавайте о новых публикациях и обсуждениях", - "Placeholder feedCollaborations": "На платформе можно писать материалы вместе. Здесь появятся публикации, в которые вы внесли вклад", - "Placeholder feedDiscussions": "Дискурс — свободная платформа для осмысленного общения. Здесь появятся все ваши реплики, чтобы в любой момент вернуться к диалогу", + "Placeholder feed": "Подпишитесь на любимые темы, авторов и сообщества —
моментально узнавайте о новых публикациях и обсуждениях", + "Placeholder feedCollaborations": "На платформе можно писать материалы вместе.
Здесь появятся публикации, в которые вы внесли вклад", + "Placeholder feedDiscussions": "Дискурс — свободная платформа для осмысленного общения.
Здесь появятся все ваши реплики, чтобы в любой момент вернуться к диалогу", "Platform Guide": "Гид по дискурсу", "Please check your email address": "Пожалуйста, проверьте введенный адрес почты", "Please check your inbox! We have sent a password reset 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/Feed/Placeholder/Placeholder.module.scss b/src/components/Feed/Placeholder/Placeholder.module.scss index abd61262..02942072 100644 --- a/src/components/Feed/Placeholder/Placeholder.module.scss +++ b/src/components/Feed/Placeholder/Placeholder.module.scss @@ -1,13 +1,13 @@ .placeholder { border-radius: 2.2rem; display: flex; - @include font-size(1.4rem); + font-size: 1.4rem; font-weight: 500; overflow: hidden; position: relative; h3 { - @include font-size(2.4rem); + font-size: 2.4rem; } button, @@ -17,6 +17,7 @@ display: flex; @include font-size(1.5rem); gap: 0.6rem; + justify-content: center; margin-top: 3rem; padding: 1rem 2rem; width: 100%; @@ -29,49 +30,72 @@ } .placeholder--feed-mode { - aspect-ratio: 1 / 0.8; flex-direction: column; + min-height: 40rem; text-align: center; - &:after { - bottom: 0; - content: ''; - height: 20%; - left: 0; - position: absolute; - width: 100%; - - .placeholder--feed & { - background: linear-gradient(to top, #171032, rgba(23, 16, 50, 0)); - } - - .placeholder--feedCollaborations & { - background: linear-gradient(to top, #070709, rgba(7, 7, 9, 0)); - } + @include media-breakpoint-up(lg) { + aspect-ratio: 1 / 0.8; } .placeholderCover { - flex: 0 100%; - width: 100%; + flex: 1 100%; + position: relative; + + &:after { + bottom: 0; + content: ''; + height: 20%; + left: 0; + position: absolute; + width: 100%; + } img { position: absolute; } } + + &.placeholder--feedMy .placeholderCover:after { + background: linear-gradient(to top, #171032, rgba(23, 16, 50, 0)); + } + + &.placeholder--feedCollaborations .placeholderCover:after { + background: linear-gradient(to top, #070709, rgba(7, 7, 9, 0)); + } } .placeholder--profile-mode { - min-height: 28rem; + min-height: 40rem; + + @include media-breakpoint-down(lg) { + display: block; + } + + @include media-breakpoint-up(lg) { + max-height: 30rem; + min-height: auto; + } .placeholderCover { - flex: 0 45rem; - min-width: 45rem; - order: 2; padding: 1.6rem; + @include media-breakpoint-up(lg) { + //flex: 0 50%; + //min-width: 50%; + order: 2; + position: static; + } + img { - height: auto; + height: 100%; + object-fit: contain; width: 100%; + //width: auto; + + @include media-breakpoint-up(lg) { + object-position: right; + } } } @@ -79,9 +103,19 @@ display: flex; flex-direction: column; justify-content: space-between; - @include font-size(2rem); + font-size: 1.4rem; line-height: 1.2; - padding: 3rem; + min-width: 60%; + padding: 0 2rem 2rem; + + @include media-breakpoint-up(md) { + font-size: 1.6rem; + padding: 3rem; + } + + @include media-breakpoint-up(lg) { + font-size: 2rem; + } } h3 { @@ -90,11 +124,18 @@ .button { background: var(--background-color-invert); - color: var(--default-color-invert); bottom: 2rem; - position: absolute; + color: var(--default-color-invert); + font-size: 1.6rem; + left: 2rem; right: 2rem; - width: auto; + width: 100%; + + @include media-breakpoint-up(lg) { + left: auto; + position: absolute; + width: auto; + } .icon { filter: invert(1); @@ -115,9 +156,15 @@ .placeholderContent { padding: 1.6rem; + + @include media-breakpoint-down(lg) { + br { + display: none; + } + } } -.placeholder--feed, +.placeholder--feedMy, .placeholder--feedCollaborations { color: var(--default-color-invert); @@ -128,7 +175,7 @@ } } -.placeholder--feed { +.placeholder--feedMy { background: #171032; .placeholderCover { @@ -190,6 +237,11 @@ @include font-size(1.6rem); gap: 4rem; + @include media-breakpoint-down(sm) { + flex-direction: column; + gap: 1.4rem; + } + a { border: none !important; padding-left: 2.6rem; diff --git a/src/components/Feed/Placeholder/Placeholder.tsx b/src/components/Feed/Placeholder/Placeholder.tsx index 94fa247a..a0b7dce2 100644 --- a/src/components/Feed/Placeholder/Placeholder.tsx +++ b/src/components/Feed/Placeholder/Placeholder.tsx @@ -16,7 +16,7 @@ export const Placeholder = (props: PlaceholderProps) => { const { author } = useSession() const data = { - feed: { + feedMy: { image: 'placeholder-feed.webp', header: t('Feed settings'), text: t('Placeholder feed'), 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) => {
-

{t('contents')}

+

{t('Contents')}

-
- -
+ +
+ +
+
-
-
-
-
    - - {(comment) => ( - handleDeleteComment(id)} - /> - )} - -
+ +
+
+
+
    + + {(comment) => ( + handleDeleteComment(id)} + /> + )} + +
+
-
+ { 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/Feed/Feed.tsx b/src/components/Views/Feed/Feed.tsx index f050871d..3cce8aec 100644 --- a/src/components/Views/Feed/Feed.tsx +++ b/src/components/Views/Feed/Feed.tsx @@ -235,10 +235,11 @@ export const FeedView = (props: Props) => {
- } - > + + + + +
  • () 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 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/feed.page.tsx b/src/pages/feed.page.tsx index 869d22da..92e7e91b 100644 --- a/src/pages/feed.page.tsx +++ b/src/pages/feed.page.tsx @@ -1,6 +1,5 @@ import { Match, Switch, createEffect, on, onCleanup } from 'solid-js' -import { AuthGuard } from '../components/AuthGuard' import { Feed } from '../components/Views/Feed' import { PageLayout } from '../components/_shared/PageLayout' import { useLocalize } from '../context/localize' @@ -48,9 +47,7 @@ export const FeedPage = () => { - - - +