Init AutoSave (#154)

* Init AutoSave

* Saving Notice

* Hide save button

* Hide save button

* Fix redirect

* resolve Conversation
This commit is contained in:
Ilya Y 2023-08-04 18:59:36 +03:00 committed by GitHub
parent 8086a54d81
commit 039b60f022
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 209 additions and 76 deletions

7
package-lock.json generated
View File

@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@hocuspocus/provider": "2.0.6",
"fast-deep-equal": "3.1.3",
"form-data": "4.0.0",
"i18next": "22.4.15",
"mailgun.js": "8.2.1",
@ -8849,8 +8850,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.2.12",
@ -25813,8 +25813,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-glob": {
"version": "3.2.12",

View File

@ -30,6 +30,7 @@
},
"dependencies": {
"@hocuspocus/provider": "2.0.6",
"fast-deep-equal": "3.1.3",
"form-data": "4.0.0",
"i18next": "22.4.15",
"mailgun.js": "8.2.1",

View File

@ -72,7 +72,6 @@
"Create gallery": "Create gallery",
"Create post": "Create post",
"Create video": "Create video",
"contents": "contents",
"Date of Birth": "Date of Birth",
"Decline": "Decline",
"Delete": "Delete",
@ -237,6 +236,7 @@
"Restore password": "Restore password",
"Save draft": "Save draft",
"Save settings": "Save settings",
"Saving...": "Saving...",
"Scroll up": "Scroll up",
"Search": "Search",
"Search author": "Search author",
@ -340,6 +340,7 @@
"cancel": "cancel",
"collections": "collections",
"community": "community",
"contents": "contents",
"delimiter": "delimiter",
"discussion": "discourse",
"drafts": "drafts",

View File

@ -76,7 +76,6 @@
"Create gallery": "Создать галерею",
"Create post": "Создать публикацию",
"Create video": "Создать видео",
"contents": "оглавление",
"Date of Birth": "Дата рождения",
"Decline": "Отмена",
"Delete": "Удалить",
@ -253,6 +252,7 @@
"Save": "Сохранить",
"Save draft": "Сохранить черновик",
"Save settings": "Сохранить настройки",
"Saving...": "Сохраняем...",
"Scroll up": "Наверх",
"Search": "Поиск",
"Search author": "Поиск автора",
@ -358,6 +358,7 @@
"cancel": "отменить",
"collections": "коллекции",
"community": "сообщество",
"contents": "оглавление",
"create_chat": "Создать чат",
"create_group": "Создать группу",
"delimiter": "разделитель",

View File

@ -25,20 +25,20 @@
.actions {
@include font-size(1.2rem);
a {
display: flex;
gap: 12px;
.actionItem {
border: 0;
display: inline-block;
}
cursor: pointer;
a + a {
margin-left: 12px;
}
&.delete {
color: #d00820;
}
.deleteLink {
color: #d00820;
}
.publishLink {
color: #2bb452;
&.publish {
color: #2bb452;
}
}
}

View File

@ -53,13 +53,18 @@ export const Draft = (props: Props) => {
<span class={styles.title}>{props.shout.title || t('Unnamed draft')}</span> {props.shout.subtitle}
</div>
<div class={styles.actions}>
<a href={getPagePath(router, 'edit', { shoutId: props.shout.id.toString() })}>{t('Edit')}</a>
<a href="#" onClick={handlePublishLinkClick} class={styles.publishLink}>
<a
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')}
</a>
<a href="#" onClick={handleDeleteLinkClick} class={styles.deleteLink}>
</span>
<span onClick={handleDeleteLinkClick} class={clsx(styles.actionItem, styles.delete)}>
{t('Delete')}
</a>
</span>
</div>
</div>
)

View File

@ -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;
}
}

View 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>
)
}

View File

@ -0,0 +1 @@
export { AutoSaveNotice } from './AutoSaveNotice'

View File

@ -1,6 +1,5 @@
import { createEffect, createSignal, Show } from 'solid-js'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import { IndexeddbPersistence } from 'y-indexeddb'
import uniqolor from 'uniqolor'
import * as Y from 'yjs'
import type { Doc } from 'yjs/dist/src/utils/Doc'
@ -58,7 +57,6 @@ type Props = {
}
const yDocs: Record<string, Doc> = {}
const persisters: Record<string, IndexeddbPersistence> = {}
const providers: Record<string, HocuspocusProvider> = {}
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: {
current: HTMLDivElement
} = {

View File

@ -25,6 +25,7 @@ export const Panel = (props: Props) => {
isEditorPanelVisible,
wordCounter,
editorRef,
form,
actions: { toggleEditorPanel, saveShout, publishShout }
} = useEditorContext()
@ -48,11 +49,11 @@ export const Panel = (props: Props) => {
})
const handleSaveClick = () => {
saveShout()
saveShout(form)
}
const handlePublishClick = () => {
publishShout()
publishShout(form)
}
const handleFixTypographyClick = () => {

View File

@ -15,17 +15,18 @@ import { Button } from '../_shared/Button'
import { useEditorContext } from '../../context/editor'
import { Popover } from '../_shared/Popover'
type HeaderAuthProps = {
type Props = {
setIsProfilePopupVisible: (value: boolean) => void
}
type IconedButton = {
type IconedButtonProps = {
value: string
icon: string
action: () => void
}
const MD_WIDTH_BREAKPOINT = 992
export const HeaderAuth = (props: HeaderAuthProps) => {
export const HeaderAuth = (props: Props) => {
const { t } = useLocalize()
const { page } = useRouter()
const [visibleWarnings, setVisibleWarnings] = createSignal(false)
@ -34,6 +35,7 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
const { session, isSessionLoaded, isAuthenticated } = useSession()
const {
form,
actions: { toggleEditorPanel, saveShout, publishShout }
} = useEditorContext()
@ -61,11 +63,11 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
}
const handleSaveButtonClick = () => {
saveShout()
saveShout(form)
}
const handlePublishButtonClick = () => {
publishShout()
publishShout(form)
}
const [width, setWidth] = createSignal(0)
@ -76,25 +78,25 @@ export const HeaderAuth = (props: HeaderAuthProps) => {
onCleanup(() => window.removeEventListener('resize', handleResize))
})
const renderIconedButton = (iconedButtonProps: IconedButton) => {
const renderIconedButton = (buttonProps: IconedButtonProps) => {
return (
<Show
when={width() < MD_WIDTH_BREAKPOINT}
fallback={
<Button
value={<span class={styles.textLabel}>{iconedButtonProps.value}</span>}
value={<span class={styles.textLabel}>{buttonProps.value}</span>}
variant={'outline'}
onClick={handleSaveButtonClick}
onClick={buttonProps.action}
/>
}
>
<Popover content={iconedButtonProps.value}>
<Popover content={buttonProps.value}>
{(ref) => (
<Button
ref={ref}
variant={'outline'}
onClick={handleSaveButtonClick}
value={<Icon name={iconedButtonProps.icon} class={styles.icon} />}
onClick={buttonProps.action}
value={<Icon name={buttonProps.icon} class={styles.icon} />}
/>
)}
</Popover>

View File

@ -5,7 +5,7 @@ import { Title } from '@solidjs/meta'
import type { Shout, Topic } from '../../graphql/types.gen'
import { apiClient } from '../../utils/apiClient'
import { useRouter } from '../../stores/router'
import { useEditorContext } from '../../context/editor'
import { ShoutForm, useEditorContext } from '../../context/editor'
import { Editor, Panel, TopicSelect, UploadModalContent } from '../Editor'
import { Icon } from '../_shared/Icon'
import { Button } from '../_shared/Button'
@ -21,11 +21,14 @@ import { slugify } from '../../utils/slugify'
import { SolidSwiper } from '../_shared/SolidSwiper'
import { DropArea } from '../_shared/DropArea'
import { LayoutType, MediaItem } from '../../pages/types'
import { clone } from '../../utils/clone'
import deepEqual from 'fast-deep-equal'
import { AutoSaveNotice } from '../Editor/AutoSaveNotice'
type Props = {
shout: Shout
}
const AUTO_SAVE_INTERVAL = 5000
const handleScrollTopButtonClick = (e) => {
e.preventDefault()
window.scrollTo({
@ -47,26 +50,35 @@ export const EditView = (props: Props) => {
const [coverImage, setCoverImage] = createSignal<string>(null)
const { page } = useRouter()
const {
form,
formErrors,
actions: { setForm, setFormErrors }
actions: { setForm, setFormErrors, saveDraft, saveDraftToLocalStorage, getDraftFromLocalStorage }
} = useEditorContext()
const shoutTopics = props.shout.topics || []
setForm({
shoutId: props.shout.id,
slug: props.shout.slug,
title: props.shout.title,
subtitle: props.shout.subtitle,
selectedTopics: shoutTopics,
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 draft = getDraftFromLocalStorage(props.shout.id)
if (draft) {
setForm(draft)
} else {
setForm({
slug: props.shout.slug,
shoutId: props.shout.id,
title: props.shout.title,
subtitle: props.shout.subtitle,
selectedTopics: shoutTopics,
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(() => {
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 (
<>
<div class={styles.container}>
<Title>{pageTitle()}</Title>
<form>
<div class="wide-container">
<AutoSaveNotice active={saving()} />
<button
class={clsx(styles.scrollTopButton, {
[styles.visible]: isScrolled()

View File

@ -29,4 +29,8 @@
width: 32px;
height: 32px;
}
.tiny & {
width: 18px;
height: 18px;
}
}

View File

@ -2,13 +2,14 @@ import styles from './Loading.module.scss'
import { clsx } from 'clsx'
type Props = {
size?: 'small'
size?: 'small' | 'tiny'
}
export const Loading = (props: Props) => {
return (
<div
class={clsx(styles.container, {
[styles.small]: props.size === 'small'
[styles.small]: props.size === 'small',
[styles.tiny]: props.size === 'tiny'
})}
>
<div class={styles.icon} />

View File

@ -15,7 +15,7 @@ type WordCounter = {
words: number
}
type ShoutForm = {
export type ShoutForm = {
layout?: string
shoutId: number
slug: string
@ -35,8 +35,11 @@ type EditorContextType = {
formErrors: Record<keyof ShoutForm, string>
editorRef: { current: () => Editor }
actions: {
saveShout: () => Promise<void>
publishShout: () => Promise<void>
saveShout: (form: ShoutForm) => 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>
deleteShout: (shoutId: number) => Promise<boolean>
toggleEditorPanel: () => void
@ -109,26 +112,26 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
return true
}
const updateShout = async ({ publish }: { publish: boolean }) => {
const updateShout = async (formToUpdate: ShoutForm, { publish }: { publish: boolean }) => {
return apiClient.updateArticle({
shoutId: form.shoutId,
shoutId: formToUpdate.shoutId,
shoutInput: {
body: form.body,
topics: form.selectedTopics.map((topic) => topic2topicInput(topic)),
body: formToUpdate.body,
topics: formToUpdate.selectedTopics.map((topic) => topic2topicInput(topic)),
// authors?: InputMaybe<Array<InputMaybe<Scalars['String']>>>
// community?: InputMaybe<Scalars['Int']>
mainTopic: topic2topicInput(form.mainTopic),
slug: form.slug,
subtitle: form.subtitle,
title: form.title,
cover: form.coverImageUrl,
media: form.media
mainTopic: topic2topicInput(formToUpdate.mainTopic),
slug: formToUpdate.slug,
subtitle: formToUpdate.subtitle,
title: formToUpdate.title,
cover: formToUpdate.coverImageUrl,
media: formToUpdate.media
},
publish
})
}
const saveShout = async () => {
const saveShout = async (formToSave: ShoutForm) => {
if (isEditorPanelVisible()) {
toggleEditorPanel()
}
@ -142,7 +145,8 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
try {
const shout = await updateShout({ publish: false })
const shout = await updateShout(formToSave, { publish: false })
removeDraftFromLocalStorage(formToSave.shoutId)
if (shout.visibility === 'owner') {
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()) {
toggleEditorPanel()
}
@ -165,7 +184,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
return
}
await updateShout({ publish: false })
await updateShout(formToPublish, { publish: false })
const slug = slugify(form.title)
setForm('slug', slug)
@ -178,7 +197,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
}
try {
await updateShout({ publish: true })
await updateShout(formToPublish, { publish: true })
openPage(router, 'feed')
} catch (error) {
console.error('[publishShout]', error)
@ -218,6 +237,9 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
const actions = {
saveShout,
saveDraft,
saveDraftToLocalStorage,
getDraftFromLocalStorage,
publishShout,
publishShoutById,
deleteShout,

View File

@ -5,6 +5,7 @@ export default gql`
loadShout(slug: $slug, shout_id: $shoutId) {
id
title
visibility
subtitle
slug
layout