Init AutoSave (#154)
* Init AutoSave * Saving Notice * Hide save button * Hide save button * Fix redirect * resolve Conversation
This commit is contained in:
parent
8086a54d81
commit
039b60f022
7
package-lock.json
generated
7
package-lock.json
generated
|
@ -10,6 +10,7 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/provider": "2.0.6",
|
"@hocuspocus/provider": "2.0.6",
|
||||||
|
"fast-deep-equal": "3.1.3",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.0",
|
||||||
"i18next": "22.4.15",
|
"i18next": "22.4.15",
|
||||||
"mailgun.js": "8.2.1",
|
"mailgun.js": "8.2.1",
|
||||||
|
@ -8849,8 +8850,7 @@
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
|
@ -25813,8 +25813,7 @@
|
||||||
"fast-deep-equal": {
|
"fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"fast-glob": {
|
"fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/provider": "2.0.6",
|
"@hocuspocus/provider": "2.0.6",
|
||||||
|
"fast-deep-equal": "3.1.3",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.0",
|
||||||
"i18next": "22.4.15",
|
"i18next": "22.4.15",
|
||||||
"mailgun.js": "8.2.1",
|
"mailgun.js": "8.2.1",
|
||||||
|
|
|
@ -72,7 +72,6 @@
|
||||||
"Create gallery": "Create gallery",
|
"Create gallery": "Create gallery",
|
||||||
"Create post": "Create post",
|
"Create post": "Create post",
|
||||||
"Create video": "Create video",
|
"Create video": "Create video",
|
||||||
"contents": "contents",
|
|
||||||
"Date of Birth": "Date of Birth",
|
"Date of Birth": "Date of Birth",
|
||||||
"Decline": "Decline",
|
"Decline": "Decline",
|
||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
|
@ -237,6 +236,7 @@
|
||||||
"Restore password": "Restore password",
|
"Restore password": "Restore password",
|
||||||
"Save draft": "Save draft",
|
"Save draft": "Save draft",
|
||||||
"Save settings": "Save settings",
|
"Save settings": "Save settings",
|
||||||
|
"Saving...": "Saving...",
|
||||||
"Scroll up": "Scroll up",
|
"Scroll up": "Scroll up",
|
||||||
"Search": "Search",
|
"Search": "Search",
|
||||||
"Search author": "Search author",
|
"Search author": "Search author",
|
||||||
|
@ -340,6 +340,7 @@
|
||||||
"cancel": "cancel",
|
"cancel": "cancel",
|
||||||
"collections": "collections",
|
"collections": "collections",
|
||||||
"community": "community",
|
"community": "community",
|
||||||
|
"contents": "contents",
|
||||||
"delimiter": "delimiter",
|
"delimiter": "delimiter",
|
||||||
"discussion": "discourse",
|
"discussion": "discourse",
|
||||||
"drafts": "drafts",
|
"drafts": "drafts",
|
||||||
|
|
|
@ -76,7 +76,6 @@
|
||||||
"Create gallery": "Создать галерею",
|
"Create gallery": "Создать галерею",
|
||||||
"Create post": "Создать публикацию",
|
"Create post": "Создать публикацию",
|
||||||
"Create video": "Создать видео",
|
"Create video": "Создать видео",
|
||||||
"contents": "оглавление",
|
|
||||||
"Date of Birth": "Дата рождения",
|
"Date of Birth": "Дата рождения",
|
||||||
"Decline": "Отмена",
|
"Decline": "Отмена",
|
||||||
"Delete": "Удалить",
|
"Delete": "Удалить",
|
||||||
|
@ -253,6 +252,7 @@
|
||||||
"Save": "Сохранить",
|
"Save": "Сохранить",
|
||||||
"Save draft": "Сохранить черновик",
|
"Save draft": "Сохранить черновик",
|
||||||
"Save settings": "Сохранить настройки",
|
"Save settings": "Сохранить настройки",
|
||||||
|
"Saving...": "Сохраняем...",
|
||||||
"Scroll up": "Наверх",
|
"Scroll up": "Наверх",
|
||||||
"Search": "Поиск",
|
"Search": "Поиск",
|
||||||
"Search author": "Поиск автора",
|
"Search author": "Поиск автора",
|
||||||
|
@ -358,6 +358,7 @@
|
||||||
"cancel": "отменить",
|
"cancel": "отменить",
|
||||||
"collections": "коллекции",
|
"collections": "коллекции",
|
||||||
"community": "сообщество",
|
"community": "сообщество",
|
||||||
|
"contents": "оглавление",
|
||||||
"create_chat": "Создать чат",
|
"create_chat": "Создать чат",
|
||||||
"create_group": "Создать группу",
|
"create_group": "Создать группу",
|
||||||
"delimiter": "разделитель",
|
"delimiter": "разделитель",
|
||||||
|
|
|
@ -25,20 +25,20 @@
|
||||||
.actions {
|
.actions {
|
||||||
@include font-size(1.2rem);
|
@include font-size(1.2rem);
|
||||||
|
|
||||||
a {
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.actionItem {
|
||||||
border: 0;
|
border: 0;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
cursor: pointer;
|
||||||
|
|
||||||
a + a {
|
&.delete {
|
||||||
margin-left: 12px;
|
color: #d00820;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deleteLink {
|
&.publish {
|
||||||
color: #d00820;
|
color: #2bb452;
|
||||||
}
|
}
|
||||||
|
|
||||||
.publishLink {
|
|
||||||
color: #2bb452;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,13 +53,18 @@ export const Draft = (props: Props) => {
|
||||||
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}
|
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}
|
||||||
</div>
|
</div>
|
||||||
<div class={styles.actions}>
|
<div class={styles.actions}>
|
||||||
<a href={getPagePath(router, 'edit', { shoutId: props.shout.id.toString() })}>{t('Edit')}</a>
|
<a
|
||||||
<a href="#" onClick={handlePublishLinkClick} class={styles.publishLink}>
|
class={styles.actionItem}
|
||||||
|
href={getPagePath(router, 'edit', { shoutId: props.shout.id.toString() })}
|
||||||
|
>
|
||||||
|
{t('Edit')}
|
||||||
|
</a>
|
||||||
|
<span onClick={handlePublishLinkClick} class={clsx(styles.actionItem, styles.publish)}>
|
||||||
{t('Publish')}
|
{t('Publish')}
|
||||||
</a>
|
</span>
|
||||||
<a href="#" onClick={handleDeleteLinkClick} class={styles.deleteLink}>
|
<span onClick={handleDeleteLinkClick} class={clsx(styles.actionItem, styles.delete)}>
|
||||||
{t('Delete')}
|
{t('Delete')}
|
||||||
</a>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
.AutoSaveNotice {
|
||||||
|
@include font-size(1.4rem);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: calc(100vh - 40px);
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: 0.6s ease-in-out;
|
||||||
|
background: rgba(white, 0.3);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border: 1px solid var(--secondary-color);
|
||||||
|
left: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
right: -14rem;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
opacity: 0.65;
|
||||||
|
right: 2rem;
|
||||||
|
}
|
||||||
|
}
|
20
src/components/Editor/AutoSaveNotice/AutoSaveNotice.tsx
Normal file
20
src/components/Editor/AutoSaveNotice/AutoSaveNotice.tsx
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import styles from './AutoSaveNotice.module.scss'
|
||||||
|
import { Loading } from '../../_shared/Loading'
|
||||||
|
import { useLocalize } from '../../../context/localize'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutoSaveNotice = (props: Props) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
return (
|
||||||
|
<div class={clsx(styles.AutoSaveNotice, { [styles.active]: props.active })}>
|
||||||
|
<div class={styles.icon}>
|
||||||
|
<Loading size="tiny" />
|
||||||
|
</div>
|
||||||
|
<div>{t('Saving...')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
1
src/components/Editor/AutoSaveNotice/index.ts
Normal file
1
src/components/Editor/AutoSaveNotice/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { AutoSaveNotice } from './AutoSaveNotice'
|
|
@ -1,6 +1,5 @@
|
||||||
import { createEffect, createSignal, Show } from 'solid-js'
|
import { createEffect, createSignal, Show } from 'solid-js'
|
||||||
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
|
||||||
import { IndexeddbPersistence } from 'y-indexeddb'
|
|
||||||
import uniqolor from 'uniqolor'
|
import uniqolor from 'uniqolor'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import type { Doc } from 'yjs/dist/src/utils/Doc'
|
import type { Doc } from 'yjs/dist/src/utils/Doc'
|
||||||
|
@ -58,7 +57,6 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const yDocs: Record<string, Doc> = {}
|
const yDocs: Record<string, Doc> = {}
|
||||||
const persisters: Record<string, IndexeddbPersistence> = {}
|
|
||||||
const providers: Record<string, HocuspocusProvider> = {}
|
const providers: Record<string, HocuspocusProvider> = {}
|
||||||
|
|
||||||
export const Editor = (props: Props) => {
|
export const Editor = (props: Props) => {
|
||||||
|
@ -80,10 +78,6 @@ export const Editor = (props: Props) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!persisters[docName]) {
|
|
||||||
persisters[docName] = new IndexeddbPersistence(docName, yDocs[docName])
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorElRef: {
|
const editorElRef: {
|
||||||
current: HTMLDivElement
|
current: HTMLDivElement
|
||||||
} = {
|
} = {
|
||||||
|
|
|
@ -25,6 +25,7 @@ export const Panel = (props: Props) => {
|
||||||
isEditorPanelVisible,
|
isEditorPanelVisible,
|
||||||
wordCounter,
|
wordCounter,
|
||||||
editorRef,
|
editorRef,
|
||||||
|
form,
|
||||||
actions: { toggleEditorPanel, saveShout, publishShout }
|
actions: { toggleEditorPanel, saveShout, publishShout }
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
|
@ -48,11 +49,11 @@ export const Panel = (props: Props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSaveClick = () => {
|
const handleSaveClick = () => {
|
||||||
saveShout()
|
saveShout(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePublishClick = () => {
|
const handlePublishClick = () => {
|
||||||
publishShout()
|
publishShout(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFixTypographyClick = () => {
|
const handleFixTypographyClick = () => {
|
||||||
|
|
|
@ -15,17 +15,18 @@ import { Button } from '../_shared/Button'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { useEditorContext } from '../../context/editor'
|
||||||
import { Popover } from '../_shared/Popover'
|
import { Popover } from '../_shared/Popover'
|
||||||
|
|
||||||
type HeaderAuthProps = {
|
type Props = {
|
||||||
setIsProfilePopupVisible: (value: boolean) => void
|
setIsProfilePopupVisible: (value: boolean) => void
|
||||||
}
|
}
|
||||||
type IconedButton = {
|
|
||||||
|
type IconedButtonProps = {
|
||||||
value: string
|
value: string
|
||||||
icon: string
|
icon: string
|
||||||
action: () => void
|
action: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MD_WIDTH_BREAKPOINT = 992
|
const MD_WIDTH_BREAKPOINT = 992
|
||||||
export const HeaderAuth = (props: HeaderAuthProps) => {
|
export const HeaderAuth = (props: Props) => {
|
||||||
const { t } = useLocalize()
|
const { t } = useLocalize()
|
||||||
const { page } = useRouter()
|
const { page } = useRouter()
|
||||||
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
|
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
|
||||||
|
@ -34,6 +35,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
const { session, isSessionLoaded, isAuthenticated } = useSession()
|
const { session, isSessionLoaded, isAuthenticated } = useSession()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
form,
|
||||||
actions: { toggleEditorPanel, saveShout, publishShout }
|
actions: { toggleEditorPanel, saveShout, publishShout }
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
|
@ -61,11 +63,11 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveButtonClick = () => {
|
const handleSaveButtonClick = () => {
|
||||||
saveShout()
|
saveShout(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePublishButtonClick = () => {
|
const handlePublishButtonClick = () => {
|
||||||
publishShout()
|
publishShout(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [width, setWidth] = createSignal(0)
|
const [width, setWidth] = createSignal(0)
|
||||||
|
@ -76,25 +78,25 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
|
||||||
onCleanup(() => window.removeEventListener('resize', handleResize))
|
onCleanup(() => window.removeEventListener('resize', handleResize))
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderIconedButton = (iconedButtonProps: IconedButton) => {
|
const renderIconedButton = (buttonProps: IconedButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={width() < MD_WIDTH_BREAKPOINT}
|
when={width() < MD_WIDTH_BREAKPOINT}
|
||||||
fallback={
|
fallback={
|
||||||
<Button
|
<Button
|
||||||
value={<span class={styles.textLabel}>{iconedButtonProps.value}</span>}
|
value={<span class={styles.textLabel}>{buttonProps.value}</span>}
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
onClick={handleSaveButtonClick}
|
onClick={buttonProps.action}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Popover content={iconedButtonProps.value}>
|
<Popover content={buttonProps.value}>
|
||||||
{(ref) => (
|
{(ref) => (
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
onClick={handleSaveButtonClick}
|
onClick={buttonProps.action}
|
||||||
value={<Icon name={iconedButtonProps.icon} class={styles.icon} />}
|
value={<Icon name={buttonProps.icon} class={styles.icon} />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Title } from '@solidjs/meta'
|
||||||
import type { Shout, Topic } from '../../graphql/types.gen'
|
import type { Shout, Topic } from '../../graphql/types.gen'
|
||||||
import { apiClient } from '../../utils/apiClient'
|
import { apiClient } from '../../utils/apiClient'
|
||||||
import { useRouter } from '../../stores/router'
|
import { useRouter } from '../../stores/router'
|
||||||
import { useEditorContext } from '../../context/editor'
|
import { ShoutForm, useEditorContext } from '../../context/editor'
|
||||||
import { Editor, Panel, TopicSelect, UploadModalContent } from '../Editor'
|
import { Editor, Panel, TopicSelect, UploadModalContent } from '../Editor'
|
||||||
import { Icon } from '../_shared/Icon'
|
import { Icon } from '../_shared/Icon'
|
||||||
import { Button } from '../_shared/Button'
|
import { Button } from '../_shared/Button'
|
||||||
|
@ -21,11 +21,14 @@ import { slugify } from '../../utils/slugify'
|
||||||
import { SolidSwiper } from '../_shared/SolidSwiper'
|
import { SolidSwiper } from '../_shared/SolidSwiper'
|
||||||
import { DropArea } from '../_shared/DropArea'
|
import { DropArea } from '../_shared/DropArea'
|
||||||
import { LayoutType, MediaItem } from '../../pages/types'
|
import { LayoutType, MediaItem } from '../../pages/types'
|
||||||
|
import { clone } from '../../utils/clone'
|
||||||
|
import deepEqual from 'fast-deep-equal'
|
||||||
|
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
shout: Shout
|
shout: Shout
|
||||||
}
|
}
|
||||||
|
const AUTO_SAVE_INTERVAL = 5000
|
||||||
const handleScrollTopButtonClick = (e) => {
|
const handleScrollTopButtonClick = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
|
@ -47,26 +50,35 @@ export const EditView = (props: Props) => {
|
||||||
const [coverImage, setCoverImage] = createSignal<string>(null)
|
const [coverImage, setCoverImage] = createSignal<string>(null)
|
||||||
|
|
||||||
const { page } = useRouter()
|
const { page } = useRouter()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
form,
|
form,
|
||||||
formErrors,
|
formErrors,
|
||||||
actions: { setForm, setFormErrors }
|
actions: { setForm, setFormErrors, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage }
|
||||||
} = useEditorContext()
|
} = useEditorContext()
|
||||||
|
|
||||||
const shoutTopics = props.shout.topics || []
|
const shoutTopics = props.shout.topics || []
|
||||||
|
|
||||||
setForm({
|
const draft = getDraftFromLocalStorage(props.shout.id)
|
||||||
shoutId: props.shout.id,
|
if (draft) {
|
||||||
slug: props.shout.slug,
|
setForm(draft)
|
||||||
title: props.shout.title,
|
} else {
|
||||||
subtitle: props.shout.subtitle,
|
setForm({
|
||||||
selectedTopics: shoutTopics,
|
slug: props.shout.slug,
|
||||||
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
|
shoutId: props.shout.id,
|
||||||
body: props.shout.body,
|
title: props.shout.title,
|
||||||
coverImageUrl: props.shout.cover,
|
subtitle: props.shout.subtitle,
|
||||||
media: props.shout.media,
|
selectedTopics: shoutTopics,
|
||||||
layout: props.shout.layout
|
mainTopic: shoutTopics.find((topic) => topic.slug === props.shout.mainTopic) || EMPTY_TOPIC,
|
||||||
})
|
body: props.shout.body,
|
||||||
|
coverImageUrl: props.shout.cover,
|
||||||
|
media: props.shout.media,
|
||||||
|
layout: props.shout.layout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prevForm, setPrevForm] = createSignal<ShoutForm>(clone(form))
|
||||||
|
const [saving, setSaving] = createSignal(false)
|
||||||
|
|
||||||
const mediaItems: Accessor<MediaItem[]> = createMemo(() => {
|
const mediaItems: Accessor<MediaItem[]> = createMemo(() => {
|
||||||
return JSON.parse(form.media || '[]')
|
return JSON.parse(form.media || '[]')
|
||||||
|
@ -195,12 +207,46 @@ export const EditView = (props: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let autoSaveTimeOutId
|
||||||
|
|
||||||
|
const autoSaveRecursive = () => {
|
||||||
|
autoSaveTimeOutId = setTimeout(async () => {
|
||||||
|
const hasChanges = !deepEqual(form, prevForm())
|
||||||
|
if (hasChanges) {
|
||||||
|
setSaving(true)
|
||||||
|
if (props.shout.visibility === 'owner') {
|
||||||
|
await saveDraft(form)
|
||||||
|
} else {
|
||||||
|
saveDraftToLocalStorage(form)
|
||||||
|
}
|
||||||
|
setPrevForm(clone(form))
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaving(false)
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
autoSaveRecursive()
|
||||||
|
}, AUTO_SAVE_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAutoSave = () => {
|
||||||
|
clearTimeout(autoSaveTimeOutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
autoSaveRecursive()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stopAutoSave()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class={styles.container}>
|
<div class={styles.container}>
|
||||||
<Title>{pageTitle()}</Title>
|
<Title>{pageTitle()}</Title>
|
||||||
<form>
|
<form>
|
||||||
<div class="wide-container">
|
<div class="wide-container">
|
||||||
|
<AutoSaveNotice active={saving()} />
|
||||||
<button
|
<button
|
||||||
class={clsx(styles.scrollTopButton, {
|
class={clsx(styles.scrollTopButton, {
|
||||||
[styles.visible]: isScrolled()
|
[styles.visible]: isScrolled()
|
||||||
|
|
|
@ -29,4 +29,8 @@
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
.tiny & {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,14 @@ import styles from './Loading.module.scss'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
size?: 'small'
|
size?: 'small' | 'tiny'
|
||||||
}
|
}
|
||||||
export const Loading = (props: Props) => {
|
export const Loading = (props: Props) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={clsx(styles.container, {
|
class={clsx(styles.container, {
|
||||||
[styles.small]: props.size === 'small'
|
[styles.small]: props.size === 'small',
|
||||||
|
[styles.tiny]: props.size === 'tiny'
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div class={styles.icon} />
|
<div class={styles.icon} />
|
||||||
|
|
|
@ -15,7 +15,7 @@ type WordCounter = {
|
||||||
words: number
|
words: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShoutForm = {
|
export type ShoutForm = {
|
||||||
layout?: string
|
layout?: string
|
||||||
shoutId: number
|
shoutId: number
|
||||||
slug: string
|
slug: string
|
||||||
|
@ -35,8 +35,11 @@ type EditorContextType = {
|
||||||
formErrors: Record<keyof ShoutForm, string>
|
formErrors: Record<keyof ShoutForm, string>
|
||||||
editorRef: { current: () => Editor }
|
editorRef: { current: () => Editor }
|
||||||
actions: {
|
actions: {
|
||||||
saveShout: () => Promise<void>
|
saveShout: (form: ShoutForm) => Promise<void>
|
||||||
publishShout: () => Promise<void>
|
saveDraft: (form: ShoutForm) => Promise<void>
|
||||||
|
saveDraftToLocalStorage: (form: ShoutForm) => void
|
||||||
|
getDraftFromLocalStorage: (shoutId: number) => ShoutForm
|
||||||
|
publishShout: (form: ShoutForm) => Promise<void>
|
||||||
publishShoutById: (shoutId: number) => Promise<void>
|
publishShoutById: (shoutId: number) => Promise<void>
|
||||||
deleteShout: (shoutId: number) => Promise<boolean>
|
deleteShout: (shoutId: number) => Promise<boolean>
|
||||||
toggleEditorPanel: () => void
|
toggleEditorPanel: () => void
|
||||||
|
@ -109,26 +112,26 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateShout = async ({ publish }: { publish: boolean }) => {
|
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
|
||||||
return apiClient.updateArticle({
|
return apiClient.updateArticle({
|
||||||
shoutId: form.shoutId,
|
shoutId: formToUpdate.shoutId,
|
||||||
shoutInput: {
|
shoutInput: {
|
||||||
body: form.body,
|
body: formToUpdate.body,
|
||||||
topics: form.selectedTopics.map((topic) => topic2topicInput(topic)),
|
topics: formToUpdate.selectedTopics.map((topic) => topic2topicInput(topic)),
|
||||||
// authors?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
|
// authors?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
|
||||||
// community?: InputMaybe<Scalars['Int']>
|
// community?: InputMaybe<Scalars['Int']>
|
||||||
mainTopic: topic2topicInput(form.mainTopic),
|
mainTopic: topic2topicInput(formToUpdate.mainTopic),
|
||||||
slug: form.slug,
|
slug: formToUpdate.slug,
|
||||||
subtitle: form.subtitle,
|
subtitle: formToUpdate.subtitle,
|
||||||
title: form.title,
|
title: formToUpdate.title,
|
||||||
cover: form.coverImageUrl,
|
cover: formToUpdate.coverImageUrl,
|
||||||
media: form.media
|
media: formToUpdate.media
|
||||||
},
|
},
|
||||||
publish
|
publish
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveShout = async () => {
|
const saveShout = async (formToSave: ShoutForm) => {
|
||||||
if (isEditorPanelVisible()) {
|
if (isEditorPanelVisible()) {
|
||||||
toggleEditorPanel()
|
toggleEditorPanel()
|
||||||
}
|
}
|
||||||
|
@ -142,7 +145,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shout = await updateShout({ publish: false })
|
const shout = await updateShout(formToSave, { publish: false })
|
||||||
|
removeDraftFromLocalStorage(formToSave.shoutId)
|
||||||
|
|
||||||
if (shout.visibility === 'owner') {
|
if (shout.visibility === 'owner') {
|
||||||
openPage(router, 'drafts')
|
openPage(router, 'drafts')
|
||||||
|
@ -155,7 +159,22 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishShout = async () => {
|
const saveDraft = async (draftForm: ShoutForm) => {
|
||||||
|
await updateShout(draftForm, { publish: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveDraftToLocalStorage = (formToSave: ShoutForm) => {
|
||||||
|
localStorage.setItem(`shout-${formToSave.shoutId}`, JSON.stringify(formToSave))
|
||||||
|
}
|
||||||
|
const getDraftFromLocalStorage = (shoutId: number) => {
|
||||||
|
return JSON.parse(localStorage.getItem(`shout-${shoutId}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeDraftFromLocalStorage = (shoutId: number) => {
|
||||||
|
localStorage.removeItem(`shout-${shoutId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishShout = async (formToPublish: ShoutForm) => {
|
||||||
if (isEditorPanelVisible()) {
|
if (isEditorPanelVisible()) {
|
||||||
toggleEditorPanel()
|
toggleEditorPanel()
|
||||||
}
|
}
|
||||||
|
@ -165,7 +184,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateShout({ publish: false })
|
await updateShout(formToPublish, { publish: false })
|
||||||
|
|
||||||
const slug = slugify(form.title)
|
const slug = slugify(form.title)
|
||||||
setForm('slug', slug)
|
setForm('slug', slug)
|
||||||
|
@ -178,7 +197,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateShout({ publish: true })
|
await updateShout(formToPublish, { publish: true })
|
||||||
openPage(router, 'feed')
|
openPage(router, 'feed')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[publishShout]', error)
|
console.error('[publishShout]', error)
|
||||||
|
@ -218,6 +237,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
saveShout,
|
saveShout,
|
||||||
|
saveDraft,
|
||||||
|
saveDraftToLocalStorage,
|
||||||
|
getDraftFromLocalStorage,
|
||||||
publishShout,
|
publishShout,
|
||||||
publishShoutById,
|
publishShoutById,
|
||||||
deleteShout,
|
deleteShout,
|
||||||
|
|
|
@ -5,6 +5,7 @@ export default gql`
|
||||||
loadShout(slug: $slug, shout_id: $shoutId) {
|
loadShout(slug: $slug, shout_id: $shoutId) {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
|
visibility
|
||||||
subtitle
|
subtitle
|
||||||
slug
|
slug
|
||||||
layout
|
layout
|
||||||
|
|
Loading…
Reference in New Issue
Block a user