editor+expo-fixes
This commit is contained in:
parent
73f78823e0
commit
30395dcfe6
|
@ -140,7 +140,8 @@ export const EditorComponent = (props: Props) => {
|
||||||
}),
|
}),
|
||||||
FloatingMenu.configure({
|
FloatingMenu.configure({
|
||||||
tippyOptions: {
|
tippyOptions: {
|
||||||
placement: 'left'
|
placement: 'left',
|
||||||
|
appendTo: document.body
|
||||||
},
|
},
|
||||||
element: floatingMenuRef()!
|
element: floatingMenuRef()!
|
||||||
}),
|
}),
|
||||||
|
@ -151,8 +152,8 @@ export const EditorComponent = (props: Props) => {
|
||||||
content: props.initialContent || null,
|
content: props.initialContent || null,
|
||||||
onTransaction: ({ editor: e, transaction }) => {
|
onTransaction: ({ editor: e, transaction }) => {
|
||||||
if (transaction.docChanged) {
|
if (transaction.docChanged) {
|
||||||
const html = e.getHTML()
|
//const html = e.getHTML()
|
||||||
html && props.onChange(html)
|
//html && props.onChange(html)
|
||||||
const wordCount: number = e.storage.characterCount.words()
|
const wordCount: number = e.storage.characterCount.words()
|
||||||
const charsCount: number = e.storage.characterCount.characters()
|
const charsCount: number = e.storage.characterCount.characters()
|
||||||
charsCount && countWords({ words: wordCount, characters: charsCount })
|
charsCount && countWords({ words: wordCount, characters: charsCount })
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { A } from '@solidjs/router'
|
import { A } from '@solidjs/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
import { For, Show, createEffect, createSignal, on } from 'solid-js'
|
||||||
import { ConditionalWrapper } from '~/components/_shared/ConditionalWrapper'
|
|
||||||
import { Loading } from '~/components/_shared/Loading'
|
import { Loading } from '~/components/_shared/Loading'
|
||||||
import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper'
|
import { ArticleCardSwiper } from '~/components/_shared/SolidSwiper/ArticleCardSwiper'
|
||||||
import { EXPO_LAYOUTS, SHOUTS_PER_PAGE } from '~/context/feed'
|
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 | '' }) => {
|
export const ExpoNav = (props: { layout: ExpoLayoutType | '' }) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<ul class={clsx('view-switcher')}>
|
<ul class={clsx('view-switcher')}>
|
||||||
<For each={[...EXPO_LAYOUTS, '']}>
|
<For each={[...EXPO_LAYOUTS, '']}>
|
||||||
{(layoutKey) => (
|
{(layoutKey) => (
|
||||||
<li class={clsx({ 'view-switcher__item--selected': props.layout === layoutKey })}>
|
<li class={clsx({ 'view-switcher__item--selected': props.layout === layoutKey })}>
|
||||||
<ConditionalWrapper
|
{props.layout !== layoutKey ? (
|
||||||
condition={props.layout !== layoutKey}
|
<A href={`/expo/${layoutKey}`}>
|
||||||
wrapper={(children) => <A href={`/expo/${layoutKey}`}>{children}</A>}
|
|
||||||
>
|
|
||||||
<span class="linkReplacement">
|
<span class="linkReplacement">
|
||||||
{layoutKey in EXPO_TITLES ? t(EXPO_TITLES[layoutKey as ExpoLayoutType]) : t('All')}
|
{layoutKey in EXPO_TITLES ? t(EXPO_TITLES[layoutKey as ExpoLayoutType]) : t('All')}
|
||||||
</span>
|
</span>
|
||||||
</ConditionalWrapper>
|
</A>
|
||||||
|
) : (
|
||||||
|
<span class="linkReplacement">
|
||||||
|
{layoutKey in EXPO_TITLES ? t(EXPO_TITLES[layoutKey as ExpoLayoutType]) : t('All')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
@ -88,13 +91,14 @@ export const Expo = (props: Props) => {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
return (
|
return (
|
||||||
<div class={styles.Expo}>
|
<div class={styles.Expo}>
|
||||||
<Show when={props.shouts} fallback={<Loading />} keyed>
|
<Show when={props.shouts} fallback={<Loading />} keyed>
|
||||||
{(feed: Shout[]) => (
|
{(feed) => (
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<For each={feed.slice(0, SHOUTS_PER_PAGE) || []}>
|
<For each={Array.from(feed || []).slice(0, SHOUTS_PER_PAGE)}>
|
||||||
{(shout) => (
|
{(shout) => (
|
||||||
<div id={`shout-${shout.id}`} class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
|
<div id={`shout-${shout.id}`} class="col-md-6 mt-md-5 col-sm-8 mt-sm-3">
|
||||||
<ArticleCard
|
<ArticleCard
|
||||||
|
@ -120,4 +124,8 @@ export const Expo = (props: Props) => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in Expo component:', error)
|
||||||
|
return <div>An error occurred. Please try again later.</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ type Props = {
|
||||||
textAreaRef?: (el: HTMLTextAreaElement) => void
|
textAreaRef?: (el: HTMLTextAreaElement) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GrowingTextarea = (props: Props) => {
|
export const GrowingTextarea = (props: Props) => {
|
||||||
const [value, setValue] = createSignal<string>('')
|
const [value, setValue] = createSignal<string>('')
|
||||||
const [isFocused, setIsFocused] = createSignal(false)
|
const [isFocused, setIsFocused] = createSignal(false)
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ export const ArticleCardSwiper = (props: Props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Show when={props.slides && props.slides.length > 0}>
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<div
|
<div
|
||||||
class={clsx({
|
class={clsx({
|
||||||
|
@ -110,5 +111,6 @@ export const ArticleCardSwiper = (props: Props) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ShowOnlyOnClient>
|
</ShowOnlyOnClient>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,28 @@
|
||||||
|
import { HocuspocusProvider } from '@hocuspocus/provider'
|
||||||
import { useMatch, useNavigate } from '@solidjs/router'
|
import { useMatch, useNavigate } from '@solidjs/router'
|
||||||
import { Editor } from '@tiptap/core'
|
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 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 { 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 { useSnackbar } from '~/context/ui'
|
||||||
import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
|
import createShoutMutation from '~/graphql/mutation/core/article-create'
|
||||||
import updateShoutQuery from '~/graphql/mutation/core/article-update'
|
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 { Topic, TopicInput } from '~/graphql/schema/core.gen'
|
||||||
import { slugify } from '~/intl/translit'
|
import { slugify } from '~/intl/translit'
|
||||||
import { useFeed } from '../context/feed'
|
import { useFeed } from '../context/feed'
|
||||||
import { useLocalize } from './localize'
|
import { useLocalize } from './localize'
|
||||||
import { useSession } from './session'
|
import { useSession } from './session'
|
||||||
|
|
||||||
|
export const AUTO_SAVE_DELAY = 3000
|
||||||
|
const yDocs: Record<string, Doc> = {}
|
||||||
|
const providers: Record<string, HocuspocusProvider> = {}
|
||||||
|
|
||||||
export type WordCounter = {
|
export type WordCounter = {
|
||||||
characters: number
|
characters: number
|
||||||
words: number
|
words: number
|
||||||
|
@ -52,6 +63,9 @@ export type EditorContextType = {
|
||||||
setEditing: SetStoreFunction<Editor | undefined>
|
setEditing: SetStoreFunction<Editor | undefined>
|
||||||
isCollabMode: Accessor<boolean>
|
isCollabMode: Accessor<boolean>
|
||||||
setIsCollabMode: SetStoreFunction<boolean>
|
setIsCollabMode: SetStoreFunction<boolean>
|
||||||
|
handleInputChange: (key: keyof ShoutForm, value: string) => void
|
||||||
|
saving: Accessor<boolean>
|
||||||
|
hasChanges: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
|
export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
|
||||||
|
@ -79,29 +93,34 @@ const removeDraftFromLocalStorage = (shoutId: number) => {
|
||||||
localStorage?.removeItem(`shout-${shoutId}`)
|
localStorage?.removeItem(`shout-${shoutId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorProvider = (props: { children: JSX.Element }) => {
|
const defaultForm: ShoutForm = {
|
||||||
const localize = useLocalize()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const matchEdit = useMatch(() => '/edit')
|
|
||||||
const matchEditSettings = useMatch(() => '/editSettings')
|
|
||||||
const { client } = useSession()
|
|
||||||
const { addFeed } = useFeed()
|
|
||||||
const snackbar = useSnackbar()
|
|
||||||
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
|
||||||
const [form, setForm] = createStore<ShoutForm>({
|
|
||||||
body: '',
|
body: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
shoutId: 0,
|
shoutId: 0,
|
||||||
title: '',
|
title: '',
|
||||||
selectedTopics: []
|
selectedTopics: []
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
|
const localize = useLocalize()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const matchEdit = useMatch(() => '/edit')
|
||||||
|
const matchEditSettings = useMatch(() => '/editSettings')
|
||||||
|
const { client, session } = useSession()
|
||||||
|
const { addFeed } = useFeed()
|
||||||
|
const snackbar = useSnackbar()
|
||||||
|
const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal<boolean>(false)
|
||||||
|
const [form, setForm] = createStore<ShoutForm>(defaultForm)
|
||||||
const [formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
|
const [formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
|
||||||
const [wordCounter, setWordCounter] = createSignal<WordCounter>({
|
const [wordCounter, setWordCounter] = createSignal<WordCounter>({ characters: 0, words: 0 })
|
||||||
characters: 0,
|
|
||||||
words: 0
|
|
||||||
})
|
|
||||||
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
|
const toggleEditorPanel = () => setIsEditorPanelVisible((value) => !value)
|
||||||
const [isCollabMode, setIsCollabMode] = createSignal<boolean>(false)
|
const [isCollabMode, setIsCollabMode] = createSignal<boolean>(false)
|
||||||
|
|
||||||
|
// current publishing editor instance to connect settings, panel and editor
|
||||||
|
const [editing, setEditing] = createSignal<Editor | undefined>(undefined)
|
||||||
|
const [saving, setSaving] = createSignal(false)
|
||||||
|
const [hasChanges, setHasChanges] = createSignal(false)
|
||||||
|
|
||||||
const countWords = (value: WordCounter) => setWordCounter(value)
|
const countWords = (value: WordCounter) => setWordCounter(value)
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
if (!form.title) {
|
if (!form.title) {
|
||||||
|
@ -131,11 +150,14 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
|
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
|
||||||
if (!formToUpdate.shoutId) {
|
if (!formToUpdate.shoutId && formToUpdate.body) {
|
||||||
console.error(formToUpdate)
|
console.debug('[updateShout] no shoutId, but body:', formToUpdate)
|
||||||
return { error: 'not enought data' }
|
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_id: formToUpdate.shoutId,
|
||||||
shout_input: {
|
shout_input: {
|
||||||
body: formToUpdate.body,
|
body: formToUpdate.body,
|
||||||
|
@ -157,15 +179,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveShout = async (formToSave: ShoutForm) => {
|
const saveShout = async (formToSave: ShoutForm) => {
|
||||||
if (isEditorPanelVisible()) {
|
isEditorPanelVisible() && toggleEditorPanel()
|
||||||
toggleEditorPanel()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchEdit() && !validate()) {
|
if ((matchEdit() && !validate()) || (matchEditSettings() && !validateSettings())) {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchEditSettings() && !validateSettings()) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,12 +192,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeDraftFromLocalStorage(formToSave.shoutId)
|
removeDraftFromLocalStorage(formToSave.shoutId)
|
||||||
|
navigate(shout?.published_at ? `/article/${shout.slug}` : '/edit')
|
||||||
if (shout?.published_at) {
|
|
||||||
navigate(`/article/${shout.slug}`)
|
|
||||||
} else {
|
|
||||||
navigate('/edit')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[saveShout]', error)
|
console.error('[saveShout]', error)
|
||||||
snackbar?.showSnackbar({ type: 'error', body: localize?.t('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) => {
|
const publishShout = async (formToPublish: ShoutForm) => {
|
||||||
if (isEditorPanelVisible()) {
|
isEditorPanelVisible() && toggleEditorPanel()
|
||||||
toggleEditorPanel()
|
|
||||||
|
if ((matchEdit() && !validate()) || (matchEditSettings() && !validateSettings())) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchEdit()) {
|
if (matchEdit()) {
|
||||||
if (!validate()) return
|
|
||||||
|
|
||||||
const slug = slugify(form.title)
|
const slug = slugify(form.title)
|
||||||
setForm('slug', slug)
|
setForm('slug', slug)
|
||||||
navigate(`/edit/${form.shoutId}/settings`)
|
navigate(`/edit/${form.shoutId}/settings`)
|
||||||
const { error } = await updateShout(formToPublish, { publish: false })
|
const { error } = await updateShout(formToPublish, { publish: false })
|
||||||
if (error) {
|
if (error) {
|
||||||
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
|
snackbar?.showSnackbar({ type: 'error', body: localize?.t(error) || '' })
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!validateSettings()) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -237,7 +244,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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
|
const result = resp?.data?.update_shout
|
||||||
if (result) {
|
if (result) {
|
||||||
const { shout: newShout, error } = result
|
const { shout: newShout, error } = result
|
||||||
|
@ -255,13 +262,13 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[publishShoutById]', 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) => {
|
const deleteShout = async (shout_id: number) => {
|
||||||
try {
|
try {
|
||||||
const resp = await client()?.mutation(deleteShoutQuery, { shout_id }).toPromise()
|
const resp = await client()?.mutation(deleteShoutMutation, { shout_id }).toPromise()
|
||||||
return resp?.data?.delete_shout
|
return resp?.data?.delete_shout
|
||||||
} catch {
|
} catch {
|
||||||
snackbar?.showSnackbar({ type: 'error', body: localize?.t('Error') || '' })
|
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 debouncedAutoSave = debounce(AUTO_SAVE_DELAY, async () => {
|
||||||
const [editing, setEditing] = createSignal<Editor | undefined>(undefined)
|
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 = {
|
const actions = {
|
||||||
saveShout,
|
saveShout,
|
||||||
|
@ -286,7 +367,10 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
setFormErrors,
|
setFormErrors,
|
||||||
setEditing,
|
setEditing,
|
||||||
isCollabMode,
|
isCollabMode,
|
||||||
setIsCollabMode
|
setIsCollabMode,
|
||||||
|
handleInputChange,
|
||||||
|
saving,
|
||||||
|
hasChanges
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: EditorContextType = {
|
const value: EditorContextType = {
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { Params, RouteSectionProps, createAsync } from '@solidjs/router'
|
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 { TopicsNav } from '~/components/HeaderNav/TopicsNav'
|
||||||
import { Expo, ExpoNav } from '~/components/Views/ExpoView'
|
import { Expo, ExpoNav } from '~/components/Views/ExpoView'
|
||||||
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
|
import { LoadMoreItems, LoadMoreWrapper } from '~/components/_shared/LoadMoreWrapper'
|
||||||
|
import { Loading } from '~/components/_shared/Loading'
|
||||||
import { PageLayout } from '~/components/_shared/PageLayout'
|
import { PageLayout } from '~/components/_shared/PageLayout'
|
||||||
import { EXPO_LAYOUTS, EXPO_TITLES, SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
|
import { EXPO_LAYOUTS, EXPO_TITLES, SHOUTS_PER_PAGE, useFeed } from '~/context/feed'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
|
@ -18,7 +20,7 @@ const fetchExpoShouts = async (layouts: string[]) => {
|
||||||
limit: SHOUTS_PER_PAGE,
|
limit: SHOUTS_PER_PAGE,
|
||||||
offset: 0
|
offset: 0
|
||||||
} as LoadShoutsOptions)
|
} as LoadShoutsOptions)
|
||||||
return result || []
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const route = {
|
export const route = {
|
||||||
|
@ -33,14 +35,14 @@ export default (props: RouteSectionProps<Shout[]>) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { expoFeed, setExpoFeed, feedByLayout } = useFeed()
|
const { expoFeed, setExpoFeed, feedByLayout } = useFeed()
|
||||||
const [loadMoreVisible, setLoadMoreVisible] = createSignal(false)
|
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(
|
const shouts = createAsync(async () =>
|
||||||
async () =>
|
isServer
|
||||||
props.data || (await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS))
|
? props.data
|
||||||
|
: await fetchExpoShouts(props.params.layout ? [props.params.layout] : EXPO_LAYOUTS)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Функция для загрузки дополнительных шотов
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
saveScrollPosition()
|
saveScrollPosition()
|
||||||
const limit = SHOUTS_PER_PAGE
|
const limit = SHOUTS_PER_PAGE
|
||||||
|
@ -48,46 +50,52 @@ export default (props: RouteSectionProps<Shout[]>) => {
|
||||||
const offset = expoFeed()?.length || 0
|
const offset = expoFeed()?.length || 0
|
||||||
const filters: LoadShoutsFilters = { layouts, featured: true }
|
const filters: LoadShoutsFilters = { layouts, featured: true }
|
||||||
const options: LoadShoutsOptions = { filters, limit, offset }
|
const options: LoadShoutsOptions = { filters, limit, offset }
|
||||||
const shoutsFetcher = loadShouts(options)
|
const fetcher = await loadShouts(options)
|
||||||
const result = await shoutsFetcher()
|
const result = (await fetcher()) || []
|
||||||
setLoadMoreVisible(Boolean(result?.length))
|
setLoadMoreVisible(Boolean(result?.length))
|
||||||
if (result) {
|
if (result && Array.isArray(result)) {
|
||||||
setExpoFeed((prev) => Array.from(new Set([...(prev || []), ...result])).sort(byCreated))
|
setExpoFeed((prev) => Array.from(new Set([...(prev || []), ...result])).sort(byCreated))
|
||||||
}
|
}
|
||||||
restoreScrollPosition()
|
restoreScrollPosition()
|
||||||
return result as LoadMoreItems
|
return result as LoadMoreItems
|
||||||
}
|
}
|
||||||
// Эффект для загрузки данных при изменении layout
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => props.params.layout as ExpoLayoutType,
|
() => props.params.layout,
|
||||||
async (layout?: ExpoLayoutType) => {
|
async (currentLayout) => {
|
||||||
const layouts = layout ? [layout] : EXPO_LAYOUTS
|
const layouts = currentLayout ? [currentLayout] : EXPO_LAYOUTS
|
||||||
const offset = (layout ? feedByLayout()[layout]?.length : expoFeed()?.length) || 0
|
const offset = (currentLayout ? feedByLayout()[currentLayout]?.length : expoFeed()?.length) || 0
|
||||||
const options: LoadShoutsOptions = {
|
const options: LoadShoutsOptions = {
|
||||||
filters: { layouts, featured: true },
|
filters: { layouts, featured: true },
|
||||||
limit: SHOUTS_PER_PAGE,
|
limit: SHOUTS_PER_PAGE,
|
||||||
offset
|
offset
|
||||||
}
|
}
|
||||||
const shoutsFetcher = loadShouts(options)
|
const result = await loadShouts(options)
|
||||||
const result = await shoutsFetcher()
|
if (result && Array.isArray(result)) {
|
||||||
setExpoFeed(result || [])
|
setExpoFeed(result)
|
||||||
|
} else {
|
||||||
|
setExpoFeed([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
withPadding={true}
|
withPadding={true}
|
||||||
zeroBottomPadding={true}
|
zeroBottomPadding={true}
|
||||||
title={`${t('Discours')} :: ${getTitle(props.params.layout || '')}`}
|
title={`${t('Discours')} :: ${getTitle()((props.params.layout as ExpoLayoutType) || '')}`}
|
||||||
>
|
>
|
||||||
<TopicsNav />
|
<TopicsNav />
|
||||||
<ExpoNav layout={(props.params.layout || '') as ExpoLayoutType | ''} />
|
<ExpoNav layout={(props.params.layout as ExpoLayoutType) || ''} />
|
||||||
|
<Show when={shouts()} fallback={<Loading />} keyed>
|
||||||
|
{(sss) => (
|
||||||
<LoadMoreWrapper loadFunction={loadMore} pageSize={SHOUTS_PER_PAGE} hidden={!loadMoreVisible()}>
|
<LoadMoreWrapper loadFunction={loadMore} pageSize={SHOUTS_PER_PAGE} hidden={!loadMoreVisible()}>
|
||||||
<Show when={shouts()} keyed>
|
<Expo shouts={sss as Shout[]} layout={(props.params.layout as ExpoLayoutType) || ''} />
|
||||||
{(sss: Shout[]) => <Expo shouts={sss} layout={props.params.layout as ExpoLayoutType} />}
|
|
||||||
</Show>
|
|
||||||
</LoadMoreWrapper>
|
</LoadMoreWrapper>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
form {
|
||||||
input[type='text'],
|
input[type='text'],
|
||||||
|
|
Loading…
Reference in New Issue
Block a user