diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx
index 9065e1f1..1acf1abe 100644
--- a/src/components/Editor/Editor.tsx
+++ b/src/components/Editor/Editor.tsx
@@ -140,7 +140,8 @@ export const EditorComponent = (props: Props) => {
}),
FloatingMenu.configure({
tippyOptions: {
- placement: 'left'
+ placement: 'left',
+ appendTo: document.body
},
element: floatingMenuRef()!
}),
@@ -151,8 +152,8 @@ export const EditorComponent = (props: Props) => {
content: props.initialContent || null,
onTransaction: ({ editor: e, transaction }) => {
if (transaction.docChanged) {
- const html = e.getHTML()
- html && props.onChange(html)
+ //const html = e.getHTML()
+ //html && props.onChange(html)
const wordCount: number = e.storage.characterCount.words()
const charsCount: number = e.storage.characterCount.characters()
charsCount && countWords({ words: wordCount, characters: charsCount })
diff --git a/src/components/Views/ExpoView.tsx b/src/components/Views/ExpoView.tsx
index 36f4bfa7..4d479d6f 100644
--- a/src/components/Views/ExpoView.tsx
+++ b/src/components/Views/ExpoView.tsx
@@ -1,7 +1,6 @@
import { A } from '@solidjs/router'
import clsx from 'clsx'
import { For, Show, createEffect, createSignal, on } from 'solid-js'
-import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
import { Loading } from '~/components/_shared/Loading'
import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper'
import { EXPO_LAYOUTS, SHOUTS_PER_PAGE } from '~/context/feed'
@@ -18,20 +17,24 @@ import styles from '~/styles/views/Expo.module.scss'
export const ExpoNav = (props: { layout: ExpoLayoutType | '' }) => {
const { t } = useLocalize()
+
return (
{(layoutKey) => (
-
- {children}}
- >
+ {props.layout !== layoutKey ? (
+
+
+ {layoutKey in EXPO_TITLES ? t(EXPO_TITLES[layoutKey as ExpoLayoutType]) : t('All')}
+
+
+ ) : (
{layoutKey in EXPO_TITLES ? t(EXPO_TITLES[layoutKey as ExpoLayoutType]) : t('All')}
-
+ )}
)}
@@ -88,36 +91,41 @@ export const Expo = (props: Props) => {
)
)
- return (
-
-
} keyed>
- {(feed: Shout[]) => (
-
-
-
- {(shout) => (
-
- )}
-
+ try {
+ return (
+
+
} keyed>
+ {(feed) => (
+
+
+
+ {(shout) => (
+
+ )}
+
+
+
+
0}>
+
+
+
+
0}>
+
+
-
-
0}>
-
-
-
-
0}>
-
-
-
- )}
-
-
- )
+ )}
+
+
+ )
+ } catch (error) {
+ console.error('Error in Expo component:', error)
+ return
An error occurred. Please try again later.
+ }
}
diff --git a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx
index c4dfea5f..6dc6de4e 100644
--- a/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx
+++ b/src/components/_shared/GrowingTextarea/GrowingTextarea.tsx
@@ -17,7 +17,7 @@ type Props = {
textAreaRef?: (el: HTMLTextAreaElement) => void
}
-const GrowingTextarea = (props: Props) => {
+export const GrowingTextarea = (props: Props) => {
const [value, setValue] = createSignal
('')
const [isFocused, setIsFocused] = createSignal(false)
diff --git a/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx b/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx
index 6892edc0..9eb018db 100644
--- a/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx
+++ b/src/components/_shared/SolidSwiper/ArticleCardSwiper.tsx
@@ -32,83 +32,85 @@ export const ArticleCardSwiper = (props: Props) => {
})
return (
-
- 1,
- [styles.articleMode]: true,
- [styles.ArticleCardSwiper]: props.slides?.length > 1
- })}
- >
-
-
-
-
-
{props.title}
+
0}>
+
+ 1,
+ [styles.articleMode]: true,
+ [styles.ArticleCardSwiper]: props.slides?.length > 1
+ })}
+ >
+
+
-
-
-
-
0}>
- }>
- }>
-
-
(mainSwipeRef = el)}
- centered-slides={true}
- observer={true}
- space-between={10}
- breakpoints={{
- 576: { spaceBetween: 20, slidesPerView: 1.5 },
- 992: { spaceBetween: 52, slidesPerView: 1.5 }
- }}
- round-lengths={true}
- loop={true}
- speed={800}
- autoplay={{
- disableOnInteraction: false,
- delay: 6000,
- pauseOnMouseEnter: true
- }}
- >
-
- {(slide, index) => (
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
-
-
-
- )}
-
-
-
mainSwipeRef?.swiper.slidePrev()}
- >
-
+
+
+
0}>
+ }>
+ }>
+
+
(mainSwipeRef = el)}
+ centered-slides={true}
+ observer={true}
+ space-between={10}
+ breakpoints={{
+ 576: { spaceBetween: 20, slidesPerView: 1.5 },
+ 992: { spaceBetween: 52, slidesPerView: 1.5 }
+ }}
+ round-lengths={true}
+ loop={true}
+ speed={800}
+ autoplay={{
+ disableOnInteraction: false,
+ delay: 6000,
+ pauseOnMouseEnter: true
+ }}
+ >
+
+ {(slide, index) => (
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+
+
+
+ )}
+
+
+
mainSwipeRef?.swiper.slidePrev()}
+ >
+
+
+
mainSwipeRef?.swiper.slideNext()}
+ >
+
+
- mainSwipeRef?.swiper.slideNext()}
- >
-
-
-
+
-
+
-
-
+
+
)
}
diff --git a/src/context/editor.tsx b/src/context/editor.tsx
index 2d1de6fb..8517315e 100644
--- a/src/context/editor.tsx
+++ b/src/context/editor.tsx
@@ -1,17 +1,28 @@
+import { HocuspocusProvider } from '@hocuspocus/provider'
import { useMatch, useNavigate } from '@solidjs/router'
import { Editor } from '@tiptap/core'
+import Collaboration from '@tiptap/extension-collaboration'
+import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import type { JSX } from 'solid-js'
-import { Accessor, createContext, createSignal, useContext } from 'solid-js'
+import { Accessor, createContext, createEffect, createSignal, on, onCleanup, useContext } from 'solid-js'
import { SetStoreFunction, createStore } from 'solid-js/store'
+import { debounce } from 'throttle-debounce'
+import uniqolor from 'uniqolor'
+import { Doc } from 'yjs'
import { useSnackbar } from '~/context/ui'
-import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
-import updateShoutQuery from '~/graphql/mutation/core/article-update'
+import createShoutMutation from '~/graphql/mutation/core/article-create'
+import deleteShoutMutation from '~/graphql/mutation/core/article-delete'
+import updateShoutMutation from '~/graphql/mutation/core/article-update'
import { Topic, TopicInput } from '~/graphql/schema/core.gen'
import { slugify } from '~/intl/translit'
import { useFeed } from '../context/feed'
import { useLocalize } from './localize'
import { useSession } from './session'
+export const AUTO_SAVE_DELAY = 3000
+const yDocs: Record
= {}
+const providers: Record = {}
+
export type WordCounter = {
characters: number
words: number
@@ -52,6 +63,9 @@ export type EditorContextType = {
setEditing: SetStoreFunction
isCollabMode: Accessor
setIsCollabMode: SetStoreFunction
+ handleInputChange: (key: keyof ShoutForm, value: string) => void
+ saving: Accessor
+ hasChanges: Accessor
}
export const EditorContext = createContext({} as EditorContextType)
@@ -79,29 +93,34 @@ const removeDraftFromLocalStorage = (shoutId: number) => {
localStorage?.removeItem(`shout-${shoutId}`)
}
+const defaultForm: ShoutForm = {
+ body: '',
+ slug: '',
+ shoutId: 0,
+ title: '',
+ selectedTopics: []
+}
+
export const EditorProvider = (props: { children: JSX.Element }) => {
const localize = useLocalize()
const navigate = useNavigate()
const matchEdit = useMatch(() => '/edit')
const matchEditSettings = useMatch(() => '/editSettings')
- const { client } = useSession()
+ const { client, session } = useSession()
const { addFeed } = useFeed()
const snackbar = useSnackbar()
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal(false)
- const [form, setForm] = createStore({
- body: '',
- slug: '',
- shoutId: 0,
- title: '',
- selectedTopics: []
- })
+ const [form, setForm] = createStore(defaultForm)
const [formErrors, setFormErrors] = createStore({} as Record)
- const [wordCounter, setWordCounter] = createSignal({
- characters: 0,
- words: 0
- })
+ const [wordCounter, setWordCounter] = createSignal({ characters: 0, words: 0 })
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
const [isCollabMode, setIsCollabMode] = createSignal(false)
+
+ // current publishing editor instance to connect settings, panel and editor
+ const [editing, setEditing] = createSignal(undefined)
+ const [saving, setSaving] = createSignal(false)
+ const [hasChanges, setHasChanges] = createSignal(false)
+
const countWords = (value: WordCounter) => setWordCounter(value)
const validate = () => {
if (!form.title) {
@@ -131,11 +150,14 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
- if (!formToUpdate.shoutId) {
- console.error(formToUpdate)
- return { error: 'not enought data' }
+ if (!formToUpdate.shoutId && formToUpdate.body) {
+ console.debug('[updateShout] no shoutId, but body:', formToUpdate)
+ const resp = await client()
+ ?.mutation(createShoutMutation, { shout: { layout: formToUpdate.layout, body: formToUpdate.body } })
+ .toPromise()
+ return resp?.data?.create_shout
}
- const resp = await client()?.mutation(updateShoutQuery, {
+ const resp = await client()?.mutation(updateShoutMutation, {
shout_id: formToUpdate.shoutId,
shout_input: {
body: formToUpdate.body,
@@ -157,15 +179,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
const saveShout = async (formToSave: ShoutForm) => {
- if (isEditorPanelVisible()) {
- toggleEditorPanel()
- }
+ isEditorPanelVisible() && toggleEditorPanel()
- if (matchEdit() && !validate()) {
- return
- }
-
- if (matchEditSettings() && !validateSettings()) {
+ if ((matchEdit() && !validate()) || (matchEditSettings() && !validateSettings())) {
return
}
@@ -176,12 +192,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
return
}
removeDraftFromLocalStorage(formToSave.shoutId)
-
- if (shout?.published_at) {
- navigate(`/article/${shout.slug}`)
- } else {
- navigate('/edit')
- }
+ navigate(shout?.published_at ? `/article/${shout.slug}` : '/edit')
} catch (error) {
console.error('[saveShout]', error)
snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
@@ -197,25 +208,21 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
const publishShout = async (formToPublish: ShoutForm) => {
- if (isEditorPanelVisible()) {
- toggleEditorPanel()
+ isEditorPanelVisible() && toggleEditorPanel()
+
+ if ((matchEdit() && !validate()) || (matchEditSettings() && !validateSettings())) {
+ return
}
if (matchEdit()) {
- if (!validate()) return
-
const slug = slugify(form.title)
setForm('slug', slug)
navigate(`/edit/${form.shoutId}/settings`)
const { error } = await updateShout(formToPublish, { publish: false })
if (error) {
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
+ return
}
- return
- }
-
- if (!validateSettings()) {
- return
}
try {
@@ -237,7 +244,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
return
}
try {
- const resp = await client()?.mutation(updateShoutQuery, { shout_id, publish: true }).toPromise()
+ const resp = await client()?.mutation(deleteShoutMutation, { shout_id, publish: true }).toPromise()
const result = resp?.data?.update_shout
if (result) {
const { shout: newShout, error } = result
@@ -255,13 +262,13 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
} catch (error) {
console.error('[publishShoutById]', error)
- snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') })
+ snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
}
}
const deleteShout = async (shout_id: number) => {
try {
- const resp = await client()?.mutation(deleteShoutQuery, { shout_id }).toPromise()
+ const resp = await client()?.mutation(deleteShoutMutation, { shout_id }).toPromise()
return resp?.data?.delete_shout
} catch {
snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
@@ -269,8 +276,82 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
}
- // current publishing editor instance to connect settings, panel and editor
- const [editing, setEditing] = createSignal(undefined)
+ const debouncedAutoSave = debounce(AUTO_SAVE_DELAY, async () => {
+ console.log('autoSave called')
+ if (hasChanges()) {
+ console.debug('saving draft', form)
+ setSaving(true)
+ saveDraftToLocalStorage(form)
+ await saveDraft(form)
+ setSaving(false)
+ setHasChanges(false)
+ }
+ })
+ onCleanup(debouncedAutoSave.cancel)
+
+ createEffect(
+ on(
+ isCollabMode,
+ (x?: boolean) => () => {
+ const editorInstance = editing()
+ if (!editorInstance) return
+ try {
+ const docName = `shout-${form.shoutId}`
+ const token = session()?.access_token || ''
+ const profile = session()?.user?.app_data?.profile
+
+ if (!(token && profile)) {
+ throw new Error('Missing authentication data')
+ }
+
+ if (!yDocs[docName]) {
+ yDocs[docName] = new Doc()
+ }
+
+ if (!providers[docName]) {
+ providers[docName] = new HocuspocusProvider({
+ url: 'wss://hocuspocus.discours.io',
+ name: docName,
+ document: yDocs[docName],
+ token
+ })
+ console.log(`[collab mode] HocuspocusProvider connected for ${docName}`)
+ }
+ if (x) {
+ const newExtensions = [
+ Collaboration.configure({ document: yDocs[docName] }),
+ CollaborationCursor.configure({
+ provider: providers[docName],
+ user: { name: profile.name, color: uniqolor(profile.slug).color }
+ })
+ ]
+ const extensions = editing()?.options.extensions.concat(newExtensions)
+ editorInstance.setOptions({ ...editorInstance.options, extensions })
+ providers[docName].connect()
+ } else if (editorInstance) {
+ providers[docName].disconnect()
+ const updatedExtensions = editorInstance.options.extensions.filter(
+ (ext) => ext.name !== 'collaboration' && ext.name !== 'collaborationCursor'
+ )
+ editorInstance.setOptions({
+ ...editorInstance.options,
+ extensions: updatedExtensions
+ })
+ }
+ } catch (error) {
+ console.error('[collab mode] error', error)
+ }
+ },
+ { defer: true }
+ )
+ )
+
+ const handleInputChange = (key: keyof ShoutForm, value: string) => {
+ console.log(`[handleInputChange] ${key}: ${value}`)
+ setForm(key, value)
+ setHasChanges(true)
+ debouncedAutoSave()
+ }
const actions = {
saveShout,
@@ -286,7 +367,10 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
setFormErrors,
setEditing,
isCollabMode,
- setIsCollabMode
+ setIsCollabMode,
+ handleInputChange,
+ saving,
+ hasChanges
}
const value: EditorContextType = {
diff --git a/src/routes/expo/[...layout].tsx b/src/routes/expo/[...layout].tsx
index fe807336..599d46f2 100644
--- a/src/routes/expo/[...layout].tsx
+++ b/src/routes/expo/[...layout].tsx
@@ -1,8 +1,10 @@
import { Params, RouteSectionProps, createAsync } from '@solidjs/router'
-import { Show, createEffect, createSignal, on } from 'solid-js'
+import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
+import { isServer } from 'solid-js/web'
import { TopicsNav } from '~/components/HeaderNav/TopicsNav'
import { Expo, ExpoNav } from '~/components/Views/ExpoView'
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
+import { Loading } from '~/components/_shared/Loading'
import { PageLayout } from '~/components/_shared/PageLayout'
import { EXPO_LAYOUTS, EXPO_TITLES, SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
@@ -18,7 +20,7 @@ const fetchExpoShouts = async (layouts: string[]) => {
limit: SHOUTS_PER_PAGE,
offset: 0
} as LoadShoutsOptions)
- return result || []
+ return result
}
export const route = {
@@ -33,14 +35,14 @@ export default (props: RouteSectionProps) => {
const { t } = useLocalize()
const { expoFeed, setExpoFeed, feedByLayout } = useFeed()
const [loadMoreVisible, setLoadMoreVisible] = createSignal(false)
- const getTitle = (l?: string) => EXPO_TITLES[(l as ExpoLayoutType) || '']
+ const getTitle = createMemo(() => (l?: string) => EXPO_TITLES[(l as ExpoLayoutType) || ''])
- const shouts = createAsync(
- async () =>
- props.data || (await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS))
+ const shouts = createAsync(async () =>
+ isServer
+ ? props.data
+ : await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS)
)
- // Функция для загрузки дополнительных шотов
const loadMore = async () => {
saveScrollPosition()
const limit = SHOUTS_PER_PAGE
@@ -48,46 +50,52 @@ export default (props: RouteSectionProps) => {
const offset = expoFeed()?.length || 0
const filters: LoadShoutsFilters = { layouts, featured: true }
const options: LoadShoutsOptions = { filters, limit, offset }
- const shoutsFetcher = loadShouts(options)
- const result = await shoutsFetcher()
+ const fetcher = await loadShouts(options)
+ const result = (await fetcher()) || []
setLoadMoreVisible(Boolean(result?.length))
- if (result) {
+ if (result && Array.isArray(result)) {
setExpoFeed((prev) => Array.from(new Set([...(prev || []), ...result])).sort(byCreated))
}
restoreScrollPosition()
return result as LoadMoreItems
}
- // Эффект для загрузки данных при изменении layout
+
createEffect(
on(
- () => props.params.layout as ExpoLayoutType,
- async (layout?: ExpoLayoutType) => {
- const layouts = layout ? [layout] : EXPO_LAYOUTS
- const offset = (layout ? feedByLayout()[layout]?.length : expoFeed()?.length) || 0
+ () => props.params.layout,
+ async (currentLayout) => {
+ const layouts = currentLayout ? [currentLayout] : EXPO_LAYOUTS
+ const offset = (currentLayout ? feedByLayout()[currentLayout]?.length : expoFeed()?.length) || 0
const options: LoadShoutsOptions = {
filters: { layouts, featured: true },
limit: SHOUTS_PER_PAGE,
offset
}
- const shoutsFetcher = loadShouts(options)
- const result = await shoutsFetcher()
- setExpoFeed(result || [])
+ const result = await loadShouts(options)
+ if (result && Array.isArray(result)) {
+ setExpoFeed(result)
+ } else {
+ setExpoFeed([])
+ }
}
)
)
+
return (
-
-
-
- {(sss: Shout[]) => }
-
-
+
+ } keyed>
+ {(sss) => (
+
+
+
+ )}
+
)
}
diff --git a/src/styles/app.scss b/src/styles/app.scss
index d990f9f7..25456b4d 100644
--- a/src/styles/app.scss
+++ b/src/styles/app.scss
@@ -198,6 +198,143 @@ button {
}
}
+.button--subscribe {
+ background: var(--background-color);
+ color: var(--default-color);
+ border: 2px solid var(--black-100);
+ font-size: 1.5rem;
+ justify-content: center;
+ padding: 0.6rem 1.2rem;
+ transition: background-color 0.2s;
+
+ img {
+ height: auto;
+ transition: filter 0.2s;
+ }
+
+ &:hover {
+ background: var(--background-color-invert);
+ color: var(--default-color-invert);
+
+ img {
+ filter: invert(1);
+ }
+ }
+}
+
+.button--light {
+ font-size:1.5rem;
+ background-color: var(--black-100);
+ border-radius: 0.8rem;
+ color: var(--default-color);
+ font-weight: 500;
+ height: auto;
+ padding: 0.6rem 1.2rem 0.6rem 1rem;
+
+ &:hover {
+ background: var(--black-300);
+ }
+}
+
+.button--subscribe-topic {
+ background: var(--background-color);
+ color: var(--default-color);
+ border: 2px solid var(--default-color);
+ border-radius: 0.8rem;
+ font-size: 1.4rem;
+ line-height: 2.8rem;
+ height: 3.2rem;
+ padding: 0 1rem;
+
+ &:hover {
+ background: var(--background-color-invert);
+ color: var(--default-color-invert);
+ opacity: 1;
+
+ .icon {
+ filter: invert(1);
+ }
+ }
+
+ &[disabled]:hover {
+ background: var(--background-color);
+ color: var(--default-color);
+ }
+
+ .icon {
+ display: inline-block;
+ margin-right: 0.3em;
+ vertical-align: text-bottom;
+ width: 1.4em;
+ }
+}
+
+.button--content-index {
+ @include media-breakpoint-up(md) {
+ margin-top: -0.5rem;
+ position: sticky;
+ top: 135px;
+ }
+
+ @include media-breakpoint-up(sm) {
+ right: $container-padding-x;
+ }
+
+ background: none;
+ border: 2px solid var(--white-500);
+ height: 3.2rem;
+ float: right;
+ padding: 0;
+ position: absolute;
+ right: $container-padding-x * 0.5;
+ top: -0.5rem;
+ width: 3.2rem;
+ z-index: 1;
+
+ .icon {
+ background: #fff;
+ transition: filter 0.3s;
+ }
+
+ .icon,
+ img {
+ height: 100%;
+ vertical-align: middle;
+ width: auto;
+ }
+
+ &:hover {
+ .icon {
+ filter: invert(1);
+ }
+ }
+
+ .expanded {
+ border-radius: 100%;
+ overflow: hidden;
+
+ img {
+ height: auto;
+ margin-top: 0.8rem;
+ }
+ }
+}
+
+.button--submit,
+.button--outline {
+ font-size:2rem;
+ padding: 1.6rem 2rem;
+}
+
+.button--outline {
+ background: none;
+ box-shadow: inset 0 0 0 2px #000;
+ color: #000;
+
+ &:hover {
+ box-shadow: inset 0 0 0 2px var(--black-300);
+ }
+}
form {
input[type='text'],