diff --git a/src/hooks/useSyncOnce.ts b/src/hooks/useSyncOnce.ts new file mode 100644 index 00000000..56412919 --- /dev/null +++ b/src/hooks/useSyncOnce.ts @@ -0,0 +1,11 @@ +import { useRef } from "react" + +/** + * @description `useSyncOnce` is a hook that will run a function once and only once, before the useEffect called, it means before `onMounted`. + */ +export const useSyncOnce = (fn: () => any) => { + const onceRef = useRef(false) + if (onceRef.current) return + fn() + onceRef.current = true +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c20dd272..7576d03d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,3 +14,12 @@ export function stripHTML(html: string) { export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function pick(obj: T, keys: K[]): Pick { + return keys.reduce((acc, key) => { + if (obj && Object.prototype.hasOwnProperty.call(obj, key)) { + acc[key] = obj[key] + } + return acc + }, {} as Pick) +} diff --git a/src/pages/dashboard/[subdomain]/editor.tsx b/src/pages/dashboard/[subdomain]/editor.tsx index 68156cb5..f81a3c4f 100644 --- a/src/pages/dashboard/[subdomain]/editor.tsx +++ b/src/pages/dashboard/[subdomain]/editor.tsx @@ -7,14 +7,17 @@ import { useRouter } from "next/router" import NodeID3 from "node-id3" import { ChangeEvent, + FC, + memo, useCallback, useEffect, - useMemo, useRef, useState, } from "react" import type { ReactElement } from "react" import toast from "react-hot-toast" +import { create } from "zustand" +import { shallow } from "zustand/shallow" import type { EditorView } from "@codemirror/view" import { useQueryClient } from "@tanstack/react-query" @@ -33,7 +36,9 @@ import { Modal } from "~/components/ui/Modal" import { UniLink } from "~/components/ui/UniLink" import { toolbars } from "~/editor" import { useDate } from "~/hooks/useDate" +import { useGetState } from "~/hooks/useGetState" import { useIsMobileLayout } from "~/hooks/useMobileLayout" +import { useSyncOnce } from "~/hooks/useSyncOnce" import { useUploadFile } from "~/hooks/useUploadFile" import { showConfetti } from "~/lib/confetti" import { getDefaultSlug } from "~/lib/default-slug" @@ -43,7 +48,7 @@ import { getPageVisibility } from "~/lib/page-helpers" import { serverSidePropsHandler } from "~/lib/server-side-props" import { delStorage, setStorage } from "~/lib/storage" import { PageVisibilityEnum } from "~/lib/types" -import { cn } from "~/lib/utils" +import { cn, pick } from "~/lib/utils" import { Rendered, renderPageContent } from "~/markdown" import { checkPageSlug } from "~/models/page.model" import { useCreateOrUpdatePage, useGetPage } from "~/queries/page" @@ -66,10 +71,30 @@ const getInputDatetimeValue = (date: Date | string, dayjs: any) => { return str.substring(0, ((str.indexOf("T") | 0) + 6) | 0) } +const initialEditorState = { + title: "", + publishedAt: new Date().toISOString(), + published: false, + excerpt: "", + slug: "", + tags: "", + content: "", +} + +const useEditorState = create< + typeof initialEditorState & { + setValues: (values: Partial) => void + } +>((set) => ({ + ...initialEditorState, + setValues: (values: any) => set(values), +})) + +type Values = typeof initialEditorState + export default function SubdomainEditor() { const router = useRouter() const queryClient = useQueryClient() - const date = useDate() const { t } = useTranslation("dashboard") let pageId = router.query.id as string | undefined @@ -119,25 +144,27 @@ export default function SubdomainEditor() { const uploadFile = useUploadFile() - const [values, setValues] = useState({ - title: "", - publishedAt: new Date().toISOString(), - published: false, - excerpt: "", - slug: "", - tags: "", - content: "", + // reset editor state when page changes + useSyncOnce(() => { + useEditorState.setState(initialEditorState) }) + + const values = useEditorState() + const [initialContent, setInitialContent] = useState("") const [defaultSlug, setDefaultSlug] = useState("") - type Values = typeof values + const getValues = useGetState(values) + const getDraftKey = useGetState(draftKey) const updateValue = useCallback( (key: K, value: Values[K]) => { if (visibility === PageVisibilityEnum.Published) { setVisibility(PageVisibilityEnum.Modified) } + + const values = getValues() + const draftKey = getDraftKey() if (key === "title") { setDefaultSlug( getDefaultSlug( @@ -163,9 +190,9 @@ export default function SubdomainEditor() { }) queryClient.invalidateQueries(["getPagesBySite", subdomain]) } - setValues(newValues) + useEditorState.setState(newValues) }, - [setValues, values, draftKey, isPost, queryClient, subdomain, t], + [isPost, queryClient, subdomain], ) const createOrUpdatePage = useCreateOrUpdatePage() @@ -202,113 +229,6 @@ export default function SubdomainEditor() { const [isCheersOpen, setIsCheersOpen] = useState(false) - const extraProperties = useMemo( - () => ( -
-
- ) => { - try { - const value = date.inLocalTimezone(e.target.value).toISOString() - updateValue("publishedAt", value) - } catch (error) {} - }} - help={t( - `This ${ - isPost ? "post" : "page" - } will be accessible from this time`, - )} - /> -
-
- ) => - updateValue("slug", e.target.value) - } - help={ - <> - {(values.slug || defaultSlug) && ( - <> - {t( - `This ${isPost ? "post" : "page"} will be accessible at`, - )}{" "} - - {getSiteLink({ - subdomain, - domain: site.data?.custom_domain, - noProtocol: true, - })} - /{encodeURIComponent(values.slug || defaultSlug)} - - - )} - - } - /> -
-
- ) => - updateValue("tags", e.target.value) - } - help={t("Separate multiple tags with English commas") + ` ","`} - /> -
-
- ) => { - updateValue("excerpt", e.target.value) - }} - help={t("Leave it blank to use auto-generated excerpt")} - /> -
-
- ), - [ - date, - defaultSlug, - isPost, - site.data?.custom_domain, - subdomain, - t, - updateValue, - values.excerpt, - values.publishedAt, - values.slug, - values.tags, - ], - ) - useEffect(() => { if (createOrUpdatePage.isSuccess) { if (createOrUpdatePage.data?.code === 0) { @@ -340,7 +260,7 @@ export default function SubdomainEditor() { useEffect(() => { if (!page.data || !draftKey) return setInitialContent(page.data.body?.content || "") - setValues({ + useEditorState.setState({ title: page.data.title || "", publishedAt: page.data.date_published, published: !!page.data.id, @@ -567,6 +487,14 @@ export default function SubdomainEditor() { )}`, ) }, [draftKey, subdomain]) + const extraProperties = ( + + ) return ( <> @@ -591,40 +519,6 @@ export default function SubdomainEditor() { {isMobileLayout ? (
- {/* - {t(visibility as string)} - */} - {/* */} - {/* */}
- {!isMobileLayout && extraProperties} + {!isMobileLayout && ( + + )} )} @@ -825,3 +726,111 @@ export default function SubdomainEditor() { SubdomainEditor.getLayout = (page: ReactElement) => { return {page} } + +const EditorExtraProperties: FC<{ + updateValue: (key: K, value: Values[K]) => void + isPost: boolean + + subdomain: string + defaultSlug: string +}> = memo(({ isPost, updateValue, subdomain, defaultSlug }) => { + const values = useEditorState( + (state) => pick(state, ["publishedAt", "slug", "excerpt", "tags"]), + shallow, + ) + const date = useDate() + const { t } = useTranslation("dashboard") + const site = useGetSite(subdomain) + + return ( +
+
+ ) => { + try { + const value = date.inLocalTimezone(e.target.value).toISOString() + updateValue("publishedAt", value) + } catch (error) {} + }} + help={t( + `This ${ + isPost ? "post" : "page" + } will be accessible from this time`, + )} + /> +
+
+ ) => + updateValue("slug", e.target.value) + } + help={ + <> + {(values.slug || defaultSlug) && ( + <> + {t(`This ${isPost ? "post" : "page"} will be accessible at`)}{" "} + + {getSiteLink({ + subdomain, + domain: site.data?.custom_domain, + noProtocol: true, + })} + /{encodeURIComponent(values.slug || defaultSlug)} + + + )} + + } + /> +
+
+ ) => + updateValue("tags", e.target.value) + } + help={t("Separate multiple tags with English commas") + ` ","`} + /> +
+
+ ) => { + updateValue("excerpt", e.target.value) + }} + help={t("Leave it blank to use auto-generated excerpt")} + /> +
+
+ ) +}) + +EditorExtraProperties.displayName = "EditorExtraProperties"