diff --git a/components/container/edit-container.tsx b/components/container/edit-container.tsx index 8e59a4af8..63323dafb 100644 --- a/components/container/edit-container.tsx +++ b/components/container/edit-container.tsx @@ -1,7 +1,7 @@ import NoteState from 'libs/web/state/note' import { has } from 'lodash' import router, { useRouter } from 'next/router' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import NoteTreeState from 'libs/web/state/tree' import NoteNav from 'components/note-nav' import UIState from 'libs/web/state/ui' @@ -10,6 +10,7 @@ import useSettingsAPI from 'libs/web/api/settings' import dynamic from 'next/dynamic' import { useToast } from 'libs/web/hooks/use-toast' import DeleteAlert from 'components/editor/delete-alert' +import useRouterWarning from 'libs/web/state/ui/useRouterWarning' const MainEditor = dynamic(() => import('components/editor/main-editor')) @@ -27,11 +28,13 @@ export const EditContainer = () => { note, } = NoteState.useContainer() const { query } = useRouter() + const [isSaved, setSaved] = useState(true) const pid = query.pid as string const id = query.id as string const isNew = has(query, 'new') const { mutate: mutateSettings } = useSettingsAPI() const toast = useToast() + const saveRef = useRef<() => void>() const loadNoteById = useCallback( async (id: string) => { @@ -94,18 +97,32 @@ export const EditContainer = () => { useEffect(() => { abortFindNote() loadNoteById(id) + setSaved(true) }, [loadNoteById, abortFindNote, id]) useEffect(() => { - updateTitle(note?.title) - }, [note?.title, updateTitle]) + updateTitle(`${!isSaved && settings.explicitSave ? '*' : ''}${note?.title}`) + }, [isSaved, note?.title, settings.explicitSave, updateTitle]) + + useRouterWarning(!isSaved && settings.explicitSave, () => { + return confirm('Warning! You have unsaved changes.') + }) return ( <> - + - + > ) diff --git a/components/editor/editor.tsx b/components/editor/editor.tsx index 5fa5733ec..57a9ce755 100644 --- a/components/editor/editor.tsx +++ b/components/editor/editor.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from 'react' +import { FC, MutableRefObject, useEffect, useState } from 'react' import { use100vh } from 'react-div-100vh' import MarkdownEditor, { Props } from 'rich-markdown-editor' import { useEditorTheme } from './theme' @@ -12,9 +12,18 @@ import { useEmbeds } from './embeds' export interface EditorProps extends Pick { isPreview?: boolean + explicitSave?: boolean + saveState?: (state: boolean) => void + saveRef?: MutableRefObject<(() => void) | undefined> } -const Editor: FC = ({ readOnly, isPreview }) => { +const Editor: FC = ({ + readOnly, + isPreview, + explicitSave, + saveState, + saveRef, +}) => { const { onSearchLink, onCreateLink, @@ -39,6 +48,16 @@ const Editor: FC = ({ readOnly, isPreview }) => { setHasMinHeight((backlinks?.length ?? 0) <= 0) }, [backlinks, isPreview]) + useEffect(() => { + const handleKeyDown = () => { + if (editorEl.current?.value) { + onEditorChange(editorEl.current?.value) + saveState && saveState(true) + } + } + if (saveRef) (saveRef as MutableRefObject<() => void>).current = handleKeyDown + }, [saveState, saveRef, editorEl, onEditorChange]) + return ( <> = ({ readOnly, isPreview }) => { id={note?.id} ref={editorEl} value={mounted ? note?.content : ''} - onChange={onEditorChange} + onChange={ + explicitSave ? () => saveState && saveState(false) : onEditorChange + } + onSave={explicitSave ? () => (saveRef as MutableRefObject<() => void>)?.current?.() : undefined} placeholder={dictionary.editorPlaceholder} theme={editorTheme} uploadImage={(file) => onUploadImage(file, note?.id)} diff --git a/components/icon-button.tsx b/components/icon-button.tsx index 48ebfc48a..ee73d0599 100644 --- a/components/icon-button.tsx +++ b/components/icon-button.tsx @@ -18,7 +18,8 @@ import { ExternalLinkIcon, BookmarkAltIcon, PuzzleIcon, - ChevronDoubleUpIcon + ChevronDoubleUpIcon, + SaveIcon, } from '@heroicons/react/outline' export const ICONS = { @@ -40,6 +41,7 @@ export const ICONS = { BookmarkAlt: BookmarkAltIcon, Puzzle: PuzzleIcon, ChevronDoubleUp: ChevronDoubleUpIcon, + Save: SaveIcon, } const IconButton = forwardRef< diff --git a/components/note-nav.tsx b/components/note-nav.tsx index bfcd884ef..e79e4dc80 100644 --- a/components/note-nav.tsx +++ b/components/note-nav.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames' import NoteState from 'libs/web/state/note' import UIState from 'libs/web/state/ui' -import { useCallback, MouseEvent } from 'react' +import { useCallback, MouseEvent, MutableRefObject } from 'react' import { CircularProgress, Tooltip } from '@material-ui/core' import NoteTreeState from 'libs/web/state/tree' import { Breadcrumbs } from '@material-ui/core' @@ -33,8 +33,13 @@ const MenuButton = () => { > ) } +type Props = { + explicitSave?: boolean + isSaved?: boolean + saveRef?: MutableRefObject<(() => void) | undefined> +} -const NoteNav = () => { +const NoteNav = ({ explicitSave, isSaved, saveRef }: Props) => { const { t } = useI18n() const { note, loading } = NoteState.useContainer() const { ua } = UIState.useContainer() @@ -61,9 +66,11 @@ const NoteNav = () => { const handleClickOpenInTree = useCallback(() => { if (!note) return - showItem(note); + showItem(note) }, [note, showItem]) + const saveNote = useCallback(() => saveRef?.current?.(), [saveRef]) + return ( { }} > {ua.isMobileOnly ? : null} + + {ua.isMobileOnly && explicitSave ? ( + + ) : null} + {note && ( diff --git a/components/portal/link-toolbar/link-toolbar.tsx b/components/portal/link-toolbar/link-toolbar.tsx index 2acd84c8c..272cf8bce 100644 --- a/components/portal/link-toolbar/link-toolbar.tsx +++ b/components/portal/link-toolbar/link-toolbar.tsx @@ -31,7 +31,9 @@ const LinkToolbar = () => { if (!result) { return } - const bookmarkUrl = `/api/extract?type=${type}&url=${encodeURIComponent(href)}` + const bookmarkUrl = `/api/extract?type=${type}&url=${encodeURIComponent( + href + )}` const transaction = state.tr.replaceWith( result.pos, result.pos + result.node.nodeSize, diff --git a/components/portal/sidebar-menu/sidebar-menu-item.tsx b/components/portal/sidebar-menu/sidebar-menu-item.tsx index c3873189d..e07f50576 100644 --- a/components/portal/sidebar-menu/sidebar-menu-item.tsx +++ b/components/portal/sidebar-menu/sidebar-menu-item.tsx @@ -27,7 +27,9 @@ interface ItemProps { export const SidebarMenuItem = forwardRef( ({ item }, ref) => { - const { settings: { settings } } = UIState.useContainer() + const { + settings: { settings }, + } = UIState.useContainer() const { removeNote, mutateNote } = NoteState.useContainer() const { menu: { close, data }, @@ -66,7 +68,7 @@ export const SidebarMenuItem = forwardRef( }) } }, [close, data, mutateNote]) - + const toggleWidth = useCallback(() => { close() if (data?.id) { diff --git a/components/portal/sidebar-menu/sidebar-menu.tsx b/components/portal/sidebar-menu/sidebar-menu.tsx index f7ce404f9..8640ca43e 100644 --- a/components/portal/sidebar-menu/sidebar-menu.tsx +++ b/components/portal/sidebar-menu/sidebar-menu.tsx @@ -49,7 +49,7 @@ const SidebarMenu: FC = () => { { text: t('Toggle width'), // TODO: or SwitchHorizontal? - icon: , + icon: , handler: MENU_HANDLER_NAME.TOGGLE_WIDTH, }, ], diff --git a/components/settings/explicit-save.tsx b/components/settings/explicit-save.tsx new file mode 100644 index 000000000..e0c85a3ca --- /dev/null +++ b/components/settings/explicit-save.tsx @@ -0,0 +1,34 @@ +import { FC, useCallback, ChangeEvent } from 'react' +import { MenuItem, TextField } from '@material-ui/core' +import router from 'next/router' +import { defaultFieldConfig } from './settings-container' +import useI18n from 'libs/web/hooks/use-i18n' +import UIState from 'libs/web/state/ui' + +export const ExplicitSave: FC = () => { + const { t } = useI18n() + const { + settings: { settings, updateSettings }, + } = UIState.useContainer() + + const handleChange = useCallback( + async (event: ChangeEvent) => { + await updateSettings({ explicitSave: Boolean(event.target.value) }) + router.reload() + }, + [updateSettings] + ) + + return ( + + {t('Explicit save (ctrl + s)')} + {t('Auto save')} + + ) +} diff --git a/components/settings/settings-container.tsx b/components/settings/settings-container.tsx index bb5331e0e..5032b2087 100644 --- a/components/settings/settings-container.tsx +++ b/components/settings/settings-container.tsx @@ -8,6 +8,7 @@ import { ImportOrExport } from './import-or-export' import { SnippetInjection } from './snippet-injection' import useI18n from 'libs/web/hooks/use-i18n' import { SettingsHeader } from './settings-header' +import { ExplicitSave } from './explicit-save' export const defaultFieldConfig: TextFieldProps = { fullWidth: true, @@ -36,6 +37,7 @@ export const SettingsContainer: FC = () => { + { moveItem, mutateItem, initLoaded, - collapseAllItems + collapseAllItems, } = NoteTreeState.useContainer() const onExpand = useCallback( diff --git a/docker-compose.yml b/docker-compose.yml index 718c8b50f..7ee5eb7f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '2' services: minio: - image: minio/minio + image: minio/minio:RELEASE.2020-11-12T22-33-34Z ports: - '9000:9000' environment: diff --git a/libs/shared/meta.ts b/libs/shared/meta.ts index 35a6a62e0..5fc939988 100644 --- a/libs/shared/meta.ts +++ b/libs/shared/meta.ts @@ -32,4 +32,9 @@ export const PAGE_META_KEY = [ export type metaKey = typeof PAGE_META_KEY[number] -export const NUMBER_KEYS: metaKey[] = ['deleted', 'shared', 'pinned', 'editorsize'] +export const NUMBER_KEYS: metaKey[] = [ + 'deleted', + 'shared', + 'pinned', + 'editorsize', +] diff --git a/libs/shared/settings.ts b/libs/shared/settings.ts index 46459f92d..be32e66f5 100644 --- a/libs/shared/settings.ts +++ b/libs/shared/settings.ts @@ -10,7 +10,8 @@ export interface Settings { last_visit?: string locale: Locale injection?: string - editorsize: EDITOR_SIZE; + editorsize: EDITOR_SIZE + explicitSave: boolean } export const DEFAULT_SETTINGS: Settings = Object.freeze({ @@ -19,6 +20,7 @@ export const DEFAULT_SETTINGS: Settings = Object.freeze({ sidebar_is_fold: false, locale: Locale.EN, editorsize: EDITOR_SIZE.SMALL, + explicitSave: false, }) export function formatSettings(body: Record = {}) { @@ -60,5 +62,9 @@ export function formatSettings(body: Record = {}) { settings.editorsize = body.editorsize } + if (isBoolean(body.explicitSave)) { + settings.explicitSave = body.explicitSave + } + return settings } diff --git a/libs/web/hooks/use-mounted.ts b/libs/web/hooks/use-mounted.ts index 8c8cda891..1b85833da 100644 --- a/libs/web/hooks/use-mounted.ts +++ b/libs/web/hooks/use-mounted.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' - const useMounted = () => { +const useMounted = () => { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) diff --git a/libs/web/state/ui/useRouterWarning.ts b/libs/web/state/ui/useRouterWarning.ts new file mode 100644 index 000000000..fe2f9e2ae --- /dev/null +++ b/libs/web/state/ui/useRouterWarning.ts @@ -0,0 +1,36 @@ +import Router from 'next/router' +import { useEffect } from 'react' + +export default function useRouterWarning( + changes: boolean, + callback: () => boolean +) { + useEffect(() => { + if (!changes) { + return + } + const routeChangeStartCallback = () => { + const ok = callback() + if (!ok) { + Router.events.emit('routeChangeError') + throw 'Abort route changes due to unsaved changes' + } + } + Router.events.on('routeChangeStart', routeChangeStartCallback) + return () => Router.events.off('routeChangeStart', routeChangeStartCallback) + }, [changes, callback]) + + useEffect(() => { + if (!changes) { + return + } + const callback = (e: BeforeUnloadEvent) => { + e.preventDefault() + return true + } + window.onbeforeunload = callback + return () => { + window.onbeforeunload = null + } + }, [changes, callback]) +} diff --git a/locales/ar.json b/locales/ar.json index f87684a1e..794ed8eb0 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -116,4 +116,4 @@ "Warning": "تحذير", "Warning notice": "إشعار التحذير", "Write something nice…": "اكتب شيئًا لطيفًا …" -} \ No newline at end of file +} diff --git a/locales/de-DE.json b/locales/de-DE.json index 5f5b0e6d9..f10a3b7e7 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -116,4 +116,4 @@ "Warning": "Warning", "Warning notice": "Warning notice", "Write something nice…": "Write something nice…" -} \ No newline at end of file +} diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 7e69a37f5..28a97114a 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -116,4 +116,4 @@ "Warning": "Attention", "Warning notice": "Attention", "Write something nice…": "Ecrivez quelquechose de bien..." -} \ No newline at end of file +} diff --git a/locales/it-IT.json b/locales/it-IT.json index c0fe8a495..eeec3b8cb 100644 --- a/locales/it-IT.json +++ b/locales/it-IT.json @@ -116,4 +116,4 @@ "Warning": "Attenzione", "Warning notice": "Nota d'attenzione", "Write something nice…": "Scrivi qualcosa di carino…" -} \ No newline at end of file +} diff --git a/locales/nl-NL.json b/locales/nl-NL.json index 02c4c4564..0ed8ddcfa 100644 --- a/locales/nl-NL.json +++ b/locales/nl-NL.json @@ -116,4 +116,4 @@ "Warning": "Waarschuwing", "Warning notice": "Waarschuwing blok", "Write something nice…": "Schrijf iets leuks…" -} \ No newline at end of file +} diff --git a/locales/ru-RU.json b/locales/ru-RU.json index 414bd7ba6..4e0a2fee4 100644 --- a/locales/ru-RU.json +++ b/locales/ru-RU.json @@ -116,4 +116,4 @@ "Warning": "Warning", "Warning notice": "Warning notice", "Write something nice…": "Write something nice…" -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 8c205bcc5..bb13e77ef 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -116,4 +116,4 @@ "Warning": "警告", "Warning notice": "警告信息", "Write something nice…": "写点什么..." -} \ No newline at end of file +} diff --git a/next-env.d.ts b/next-env.d.ts index 9bc3dd46b..534a39ea7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js index 469806e42..52f0bcb03 100644 --- a/next.config.js +++ b/next.config.js @@ -7,6 +7,6 @@ module.exports = withPWA({ pwa: { disable: process.env.NODE_ENV === 'development', dest: 'public', - runtimeCaching: cache + runtimeCaching: cache, }, }) diff --git a/scripts/cache.js b/scripts/cache.js index 912650bab..0cc2b839d 100644 --- a/scripts/cache.js +++ b/scripts/cache.js @@ -31,19 +31,19 @@ module.exports = [ cacheName: 'static-font-assets', expiration: { maxEntries: 4, - maxAgeSeconds: 7 * 24 * 60 * 60 // 7 days - } - } + maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days + }, + }, }, { urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i, - handler: 'NetworkOnly' + handler: 'NetworkOnly', }, { urlPattern: /\/_next\/image\?url=.+$/i, - handler: "StaleWhileRevalidate", + handler: 'StaleWhileRevalidate', options: { - cacheName: "next-image", + cacheName: 'next-image', expiration: { maxEntries: 64, maxAgeSeconds: 24 * 60 * 60, // 24 hours @@ -58,9 +58,9 @@ module.exports = [ cacheName: 'static-audio-assets', expiration: { maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60 // 24 hours - } - } + maxAgeSeconds: 24 * 60 * 60, // 24 hours + }, + }, }, { urlPattern: /\.(?:mp4)$/i, @@ -70,9 +70,9 @@ module.exports = [ cacheName: 'static-video-assets', expiration: { maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60 // 24 hours - } - } + maxAgeSeconds: 24 * 60 * 60, // 24 hours + }, + }, }, { urlPattern: /\.(?:js)$/i, @@ -81,9 +81,9 @@ module.exports = [ cacheName: 'static-js-assets', expiration: { maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60 // 24 hours - } - } + maxAgeSeconds: 24 * 60 * 60, // 24 hours + }, + }, }, { urlPattern: /\.(?:css|less)$/i, @@ -92,19 +92,19 @@ module.exports = [ cacheName: 'static-style-assets', expiration: { maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60 // 24 hours - } - } + maxAgeSeconds: 24 * 60 * 60, // 24 hours + }, + }, }, { urlPattern: /\/_next\/data\/.+\/.+\.json$/i, - handler: "StaleWhileRevalidate", + handler: 'StaleWhileRevalidate', options: { - cacheName: "next-data", + cacheName: 'next-data', expiration: { maxEntries: 32, maxAgeSeconds: 24 * 60 * 60, // 24 hours - } + }, }, }, { @@ -114,12 +114,12 @@ module.exports = [ cacheName: 'static-data-assets', expiration: { maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60 // 24 hours - } - } + maxAgeSeconds: 24 * 60 * 60, // 24 hours + }, + }, }, { - urlPattern: ({url}) => { + urlPattern: ({ url }) => { const isSameOrigin = self.origin === url.origin if (!isSameOrigin) return false const pathname = url.pathname @@ -136,13 +136,13 @@ module.exports = [ cacheName: 'apis', expiration: { maxEntries: 16, - maxAgeSeconds: 24 * 60 * 60 // 24 hours + maxAgeSeconds: 24 * 60 * 60, // 24 hours }, - networkTimeoutSeconds: 10 // fall back to cache if api does not response within 10 seconds - } + networkTimeoutSeconds: 10, // fall back to cache if api does not response within 10 seconds + }, }, { - urlPattern: ({url}) => { + urlPattern: ({ url }) => { const isSameOrigin = self.origin === url.origin if (!isSameOrigin) return false const pathname = url.pathname @@ -154,13 +154,13 @@ module.exports = [ cacheName: 'others', expiration: { maxEntries: 32, - maxAgeSeconds: 24 * 60 * 60 // 24 hours + maxAgeSeconds: 24 * 60 * 60, // 24 hours }, - networkTimeoutSeconds: 10 - } + networkTimeoutSeconds: 10, + }, }, { - urlPattern: ({url}) => { + urlPattern: ({ url }) => { const isSameOrigin = self.origin === url.origin return !isSameOrigin }, @@ -169,9 +169,9 @@ module.exports = [ cacheName: 'cross-origin', expiration: { maxEntries: 32, - maxAgeSeconds: 60 * 60 // 1 hour + maxAgeSeconds: 60 * 60, // 1 hour }, - networkTimeoutSeconds: 10 - } - } + networkTimeoutSeconds: 10, + }, + }, ]