diff --git a/package-lock.json b/package-lock.json
index 62b9431f..daf1ca0b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index e0a32a61..855840f8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 1cb4269e..e23bb13c 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -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",
diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json
index 9814529a..ee2fa019 100644
--- a/public/locales/ru/translation.json
+++ b/public/locales/ru/translation.json
@@ -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": "разделитель",
diff --git a/src/components/Draft/Draft.module.scss b/src/components/Draft/Draft.module.scss
index 96b207ca..52b0804e 100644
--- a/src/components/Draft/Draft.module.scss
+++ b/src/components/Draft/Draft.module.scss
@@ -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;
+ }
}
}
diff --git a/src/components/Draft/Draft.tsx b/src/components/Draft/Draft.tsx
index c600743c..f5095a65 100644
--- a/src/components/Draft/Draft.tsx
+++ b/src/components/Draft/Draft.tsx
@@ -53,13 +53,18 @@ export const Draft = (props: Props) => {
{props.shout.title || t('Unnamed draft')} {props.shout.subtitle}
)
diff --git a/src/components/Editor/AutoSaveNotice/AutoSaveNotice.module.scss b/src/components/Editor/AutoSaveNotice/AutoSaveNotice.module.scss
new file mode 100644
index 00000000..84acea15
--- /dev/null
+++ b/src/components/Editor/AutoSaveNotice/AutoSaveNotice.module.scss
@@ -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;
+ }
+}
diff --git a/src/components/Editor/AutoSaveNotice/AutoSaveNotice.tsx b/src/components/Editor/AutoSaveNotice/AutoSaveNotice.tsx
new file mode 100644
index 00000000..35669e97
--- /dev/null
+++ b/src/components/Editor/AutoSaveNotice/AutoSaveNotice.tsx
@@ -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 (
+
+
+
+
+
{t('Saving...')}
+
+ )
+}
diff --git a/src/components/Editor/AutoSaveNotice/index.ts b/src/components/Editor/AutoSaveNotice/index.ts
new file mode 100644
index 00000000..a01de3fa
--- /dev/null
+++ b/src/components/Editor/AutoSaveNotice/index.ts
@@ -0,0 +1 @@
+export { AutoSaveNotice } from './AutoSaveNotice'
diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx
index a1f2baa7..56bb2514 100644
--- a/src/components/Editor/Editor.tsx
+++ b/src/components/Editor/Editor.tsx
@@ -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 = {}
-const persisters: Record = {}
const providers: Record = {}
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
} = {
diff --git a/src/components/Editor/Panel/Panel.tsx b/src/components/Editor/Panel/Panel.tsx
index 5024e05e..515f0019 100644
--- a/src/components/Editor/Panel/Panel.tsx
+++ b/src/components/Editor/Panel/Panel.tsx
@@ -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 = () => {
diff --git a/src/components/Nav/HeaderAuth.tsx b/src/components/Nav/HeaderAuth.tsx
index c7ecdf34..8359063c 100644
--- a/src/components/Nav/HeaderAuth.tsx
+++ b/src/components/Nav/HeaderAuth.tsx
@@ -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 (
{iconedButtonProps.value}}
+ value={{buttonProps.value}}
variant={'outline'}
- onClick={handleSaveButtonClick}
+ onClick={buttonProps.action}
/>
}
>
-
+
{(ref) => (
}
+ onClick={buttonProps.action}
+ value={}
/>
)}
diff --git a/src/components/Views/Edit.tsx b/src/components/Views/Edit.tsx
index 04350ee0..daaaf92d 100644
--- a/src/components/Views/Edit.tsx
+++ b/src/components/Views/Edit.tsx
@@ -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(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(clone(form))
+ const [saving, setSaving] = createSignal(false)
const mediaItems: Accessor = 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 (
<>