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", "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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "разделитель",

View File

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

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

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 { 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
} = { } = {

View File

@ -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 = () => {

View File

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

View File

@ -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()

View File

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

View File

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

View File

@ -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,

View File

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