From 48aa67e784bc43762a9311b80c88f66903c2af65 Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 7 Jul 2023 23:40:05 +0800 Subject: [PATCH] feat: introduce `ModalStack` component for declarative modal creation (#741) --- .../[subdomain]/editor/page-component.tsx | 328 +++++++++--------- src/app/providers.tsx | 6 +- src/components/common/PatronButton.tsx | 10 +- src/components/common/PatronModal.tsx | 60 ++-- src/components/common/ReactionLike.tsx | 179 +++++++--- src/components/common/ReactionMint.tsx | 94 +++-- src/components/common/ReactionShare.tsx | 101 +++--- src/components/common/ReactionTip.tsx | 15 +- src/components/ui/Modal.tsx | 10 +- src/components/ui/ModalStack.tsx | 165 +++++++++ src/components/ui/TagInput.tsx | 2 +- .../{useEdtiorState.ts => useEditorState.ts} | 0 12 files changed, 638 insertions(+), 332 deletions(-) create mode 100644 src/components/ui/ModalStack.tsx rename src/hooks/{useEdtiorState.ts => useEditorState.ts} (100%) diff --git a/src/app/dashboard/[subdomain]/editor/page-component.tsx b/src/app/dashboard/[subdomain]/editor/page-component.tsx index 13e917cb..50f08af8 100644 --- a/src/app/dashboard/[subdomain]/editor/page-component.tsx +++ b/src/app/dashboard/[subdomain]/editor/page-component.tsx @@ -7,6 +7,7 @@ import dynamic from "next/dynamic" import { useParams, useRouter, useSearchParams } from "next/navigation" import { ChangeEvent, + FC, memo, useCallback, useEffect, @@ -30,7 +31,7 @@ import { EditorToolbar } from "~/components/ui/EditorToolbar" import { FieldLabel } from "~/components/ui/FieldLabel" import { ImageUploader } from "~/components/ui/ImageUploader" import { Input } from "~/components/ui/Input" -import { Modal } from "~/components/ui/Modal" +import { ModalContentProps, useModalStack } from "~/components/ui/ModalStack" import { Switch } from "~/components/ui/Switch" import { TagInput } from "~/components/ui/TagInput" import { UniLink } from "~/components/ui/UniLink" @@ -40,7 +41,7 @@ import { Values, initialEditorState, useEditorState, -} from "~/hooks/useEdtiorState" +} from "~/hooks/useEditorState" import { useGetState } from "~/hooks/useGetState" import { useIsMobileLayout } from "~/hooks/useMobileLayout" import { useBeforeMounted } from "~/hooks/useSyncOnce" @@ -258,7 +259,28 @@ export default function SubdomainEditor() { } }, [deleteP.isSuccess]) - const [isCheersOpen, setIsCheersOpen] = useState(false) + const { present, dismiss } = useModalStack() + + const postUrl = `${getSiteLink({ + subdomain, + domain: site.data?.metadata?.content?.custom_domain, + })}/${encodeURIComponent(values.slug || defaultSlug)}` + const transactionUrl = `${CSB_SCAN}/tx/${ + page.data?.updatedTransactionHash || page.data?.transactionHash + }` + + const twitterShareUrl = + page.data && site.data + ? getTwitterShareUrl({ + page: page.data, + site: site.data, + t, + }) + : "" + + const getPostUrl = useGetState(postUrl) + const getTransactionUrl = useGetState(transactionUrl) + const getTwitterShareUrl_ = useGetState(twitterShareUrl) useEffect(() => { if (createPage.isSuccess || updatePage.isSuccess) { @@ -283,8 +305,20 @@ export default function SubdomainEditor() { }&type=${searchParams?.get("type")}`, ) } + const modalId = "publish-modal" + present({ + title: `🎉 ${t("Published!")}`, + id: modalId, + content: (props) => ( + + ), + }) - setIsCheersOpen(true) showConfetti() createPage.reset() @@ -485,9 +519,7 @@ export default function SubdomainEditor() { ) }, [draftKey, subdomain, site.data?.characterId]) - const [isAdvancedOptionsOpen, setIsAdvancedOptionsOpen] = - useState(false) - + const presentAdvancedModal = useEditorAdvancedModal({ isPost }) const extraProperties = ( setIsAdvancedOptionsOpen(true)} + openAdvancedOptions={presentAdvancedModal} /> ) @@ -681,75 +713,13 @@ export default function SubdomainEditor() { isPost={isPost} userTags={userTags.data?.list || []} subdomain={subdomain} - openAdvancedOptions={() => setIsAdvancedOptionsOpen(true)} + openAdvancedOptions={presentAdvancedModal} /> )} )} - - -
- {t( - "Your post has been securely stored on the blockchain. Now you may want to", - )} -
    -
  • - - {t("View the post")} - -
  • -
  • - - {t("View the transaction")} - -
  • -
  • - - {t("Share to Twitter")} - -
  • -
-
-
- -
-
) } @@ -896,93 +866,139 @@ const EditorExtraProperties = memo( EditorExtraProperties.displayName = "EditorExtraProperties" -const EditorAdvancedOptions = memo( - ({ - updateValue, - isAdvancedOptionsOpen, - setIsAdvancedOptionsOpen, - isPost, - }: { - updateValue: (key: K, value: Values[K]) => void +const useEditorAdvancedModal = ({ isPost }: { isPost: boolean }) => { + const { t } = useTranslation("dashboard") - isAdvancedOptionsOpen: boolean - setIsAdvancedOptionsOpen: (state: boolean) => void - isPost: boolean - }) => { - const values = useEditorState( - (state) => pick(state, ["disableAISummary", "publishedAt"]), - shallow, - ) - const { t } = useTranslation("dashboard") + const { present } = useModalStack() - return ( - -
-
- - updateValue("disableAISummary", state)} - /> -
-
- - { - if (value) { - updateValue("publishedAt", value.toISOString()) - } else { - updateValue("publishedAt", "") - } - }} - styles={{ - input: { - borderRadius: "0.5rem", - borderColor: "var(--border-color)", - height: "2.5rem", - "&:focus-within": { - borderColor: "var(--theme-color)", - }, - }, - }} - /> -
- {t( - `This ${ - isPost ? "post" : "page" - } will be accessible from this time. Leave blank to use the current time.`, - )} -
- {values.publishedAt > new Date().toISOString() && ( -
- {t( - "The post is currently not public as its publication date has been scheduled for a future time.", - )} -
+ return () => { + present({ + title: t("Advanced Settings"), + content: () => , + }) + } +} + +const EditorAdvancedModal: FC<{ + isPost: boolean +}> = ({ isPost }) => { + const { t } = useTranslation("dashboard") + + const values = useEditorState( + (state) => pick(state, ["disableAISummary", "publishedAt"]), + shallow, + ) + const updateValue = useEditorState.setState + return ( +
+
+ + updateValue("disableAISummary", state)} + setChecked={(state) => { + updateValue({ + disableAISummary: state, + }) + }} + /> +
+
+ + { + if (value) { + updateValue({ + publishedAt: value.toISOString(), + }) + } else { + updateValue({ + publishedAt: "", + }) + } + }} + styles={{ + input: { + borderRadius: "0.5rem", + borderColor: "var(--border-color)", + height: "2.5rem", + "&:focus-within": { + borderColor: "var(--theme-color)", + }, + }, + }} + /> +
+ {t( + `This ${ + isPost ? "post" : "page" + } will be accessible from this time. Leave blank to use the current time.`, + )} +
+ {values.publishedAt > new Date().toISOString() && ( +
+ {t( + "The post is currently not public as its publication date has been scheduled for a future time.", )}
-
- - ) - }, -) + )} +
+
+ ) +} -EditorAdvancedOptions.displayName = "EditorAdvancedOptions" +const PublishedModal = ({ + dismiss, + postUrl, + transactionUrl, + twitterShareUrl, +}: ModalContentProps<{ + postUrl: string + transactionUrl: string + twitterShareUrl: string +}>) => { + const { t } = useTranslation("dashboard") + return ( + <> +
+ {t( + "Your post has been securely stored on the blockchain. Now you may want to", + )} +
    +
  • + + {t("View the post")} + +
  • +
  • + + {t("View the transaction")} + +
  • +
  • + + {t("Share to Twitter")} + +
  • +
+
+
+ +
+ + ) +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 0b7c1bdc..ac58d54b 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -11,6 +11,7 @@ import { import { QueryClient } from "@tanstack/react-query" import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client" +import { ModalStackProvider } from "~/components/ui/ModalStack" // eslint-disable-next-line import/no-unresolved import { useDarkMode } from "~/hooks/useDarkMode" import { useMobileLayout } from "~/hooks/useMobileLayout" @@ -84,7 +85,10 @@ export default function Providers({ signInStrategy="simple" ignoreWalletDisconnectEvent={true} > - {children} + + {children} + + void }) => { const { t } = useTranslation("common") - const [open, setOpen] = useState(false) + + const presentPatronModal = usePatronModal() return ( <> -
-
+ ) } diff --git a/src/components/common/ReactionLike.tsx b/src/components/common/ReactionLike.tsx index fb6c05ca..d44d2b94 100644 --- a/src/components/common/ReactionLike.tsx +++ b/src/components/common/ReactionLike.tsx @@ -4,7 +4,6 @@ import confetti from "canvas-confetti" import { useEffect, useMemo, useRef, useState } from "react" import { CharacterList } from "~/components/common/CharacterList" -import { Modal } from "~/components/ui/Modal" import { Tooltip } from "~/components/ui/Tooltip" import { UniLink } from "~/components/ui/UniLink" import { CSB_SCAN } from "~/lib/env" @@ -19,6 +18,7 @@ import { import { AvatarStack } from "../ui/AvatarStack" import { Button } from "../ui/Button" +import { ModalContentProps, useModalStack } from "../ui/ModalStack" export const ReactionLike = ({ size, @@ -32,9 +32,8 @@ export const ReactionLike = ({ vertical?: boolean }) => { const toggleLikePage = useToggleLikePage() - const { t, i18n } = useTranslation("common") + const { t } = useTranslation("common") - const [isLikeOpen, setIsLikeOpen] = useState(false) const [isLikeListOpen, setIsLikeListOpen] = useState(false) const likeRef = useRef(null) @@ -51,12 +50,37 @@ export const ReactionLike = ({ noteId, }) - const [isUnlikeOpen, setIsUnlikeOpen] = useState(false) + const { present } = useModalStack() + const presentUnlikeModal = () => { + if (characterId && noteId) { + present({ + title: t("Confirm to revert"), + content: (props) => ( + + ), + }) + } + } const like = () => { if (characterId && noteId) { if (likeStatus.isLiked) { - setIsLikeOpen(true) + present({ + title: t("Like successfully") || "", + content: (props) => ( + + ), + }) } else { toggleLikePage.mutate({ characterId, @@ -69,7 +93,7 @@ export const ReactionLike = ({ const unlike = () => { if (characterId && noteId) { - setIsUnlikeOpen(false) + presentUnlikeModal() if (likeStatus.isLiked) { toggleLikePage.mutate({ noteId, @@ -167,57 +191,7 @@ export const ReactionLike = ({ /> )} - -
- - Your like has been stored on the blockchain, view it on{" "} - - Crossbell Scan - - -
-
- - -
-
- -
- - Do you really want to revert this like action? - -
-
- - -
-
+ {showAvatarStack && ( ) } + +const LikeModal = ({ + dismiss, + likeStatus, + + presentUnlikeModal, +}: ModalContentProps<{ + likeStatus: any + + presentUnlikeModal: () => void +}>) => { + const { t, i18n } = useTranslation("common") + + return ( + <> +
+ + Your like has been stored on the blockchain, view it on{" "} + + Crossbell Scan + + +
+
+ + +
+ + ) +} + +const UnLikeModal = ({ + dismiss, + characterId, + noteId, + likeStatus, + toggleLikePage, +}: ModalContentProps<{ + characterId: number + noteId: number + likeStatus: any + toggleLikePage: ReturnType +}>) => { + const { t, i18n } = useTranslation("common") + + const { present } = useModalStack() + const unlike = () => { + if (characterId && noteId) { + dismiss() + if (likeStatus.isLiked) { + toggleLikePage.mutate({ + noteId, + characterId, + action: "unlink", + }) + } // else do nothing + } + } + + return ( + <> +
+ + Do you really want to revert this like action? + +
+
+ + +
+ + ) +} diff --git a/src/components/common/ReactionMint.tsx b/src/components/common/ReactionMint.tsx index 8cdce5fe..9e7f0401 100644 --- a/src/components/common/ReactionMint.tsx +++ b/src/components/common/ReactionMint.tsx @@ -1,12 +1,11 @@ "use client" import confetti from "canvas-confetti" -import { useEffect, useMemo, useRef, useState } from "react" +import { FC, useEffect, useMemo, useRef, useState } from "react" import { useAccountState } from "@crossbell/connect-kit" import { CharacterList } from "~/components/common/CharacterList" -import { Modal } from "~/components/ui/Modal" import { Tooltip } from "~/components/ui/Tooltip" import { UniLink } from "~/components/ui/UniLink" import { CSB_SCAN, CSB_XCHAR } from "~/lib/env" @@ -17,6 +16,7 @@ import { useCheckMint, useGetMints, useMintPage } from "~/queries/page" import { AvatarStack } from "../ui/AvatarStack" import { Button } from "../ui/Button" +import { ModalContentProps, useModalStack } from "../ui/ModalStack" export const ReactionMint = ({ size, @@ -34,7 +34,6 @@ export const ReactionMint = ({ const account = useAccountState((s) => s.computed.account) - const [isMintOpen, setIsMintOpen] = useState(false) const [isMintListOpen, setIsMintListOpen] = useState(false) const mintRef = useRef(null) @@ -47,11 +46,14 @@ export const ReactionMint = ({ characterId, noteId, }) - + const presentMintModal = usePresentMintModal({ + handle: account?.character?.handle || "", + transactionHash: isMint.data?.list?.[0]?.transactionHash || "", + }) const mint = () => { if (characterId && noteId) { if (isMint.data?.count) { - setIsMintOpen(true) + presentMintModal() } else { mintPage.mutate({ characterId, @@ -158,35 +160,7 @@ export const ReactionMint = ({ /> )} - -
- - This post has been minted to NFT by you, view it on{" "} - - xChar - {" "} - or{" "} - - Crossbell Scan - - -
-
- -
-
+ {showAvatarStack && ( ) } + +const usePresentMintModal = (props: MintModalProps) => { + const { present } = useModalStack() + const { t, i18n } = useTranslation("common") + return () => { + present({ + title: t("Mint successfully") || "", + content: (rest) => , + }) + } +} + +interface MintModalProps { + handle: string + transactionHash: string +} + +const MintModal: FC> = ({ + handle, + dismiss, + transactionHash, +}) => { + const { t, i18n } = useTranslation("common") + + return ( +
+
+ + This post has been minted to NFT by you, view it on{" "} + + xChar + {" "} + or{" "} + + Crossbell Scan + + +
+
+ +
+
+ ) +} diff --git a/src/components/common/ReactionShare.tsx b/src/components/common/ReactionShare.tsx index da45f387..7dfe69cb 100644 --- a/src/components/common/ReactionShare.tsx +++ b/src/components/common/ReactionShare.tsx @@ -1,5 +1,5 @@ import dynamic from "next/dynamic" -import { FC, useState } from "react" +import { FC } from "react" import { toast } from "react-hot-toast" import { useIsClient } from "~/hooks/useClient" @@ -7,7 +7,7 @@ import { useTranslation } from "~/lib/i18n/client" import { cn } from "~/lib/utils" import { Button } from "../ui/Button" -import { Modal } from "../ui/Modal" +import { useModalStack } from "../ui/ModalStack" import { Tooltip } from "../ui/Tooltip" const QRCodeSVG = dynamic( @@ -59,19 +59,26 @@ export const ReactionShare: FC<{ size?: "sm" | "base" }> = ({ vertical, size }) => { const { t } = useTranslation("common") - const [isShareOpen, setIsShareOpen] = useState(false) const isClient = useIsClient() + + const presentModal = usePresentShareModal() + if (!isClient) return null + // TODO: get from Note + const handleShare = () => { - setIsShareOpen(true) + const title = document.title + const url = location.href + const text = t("Share Message", { title }) + presentModal({ + url, + title, + text, + }) } - // TODO: get from Note - const title = document.title - const url = location.href - const text = t("Share Message", { title }) return ( <>
@@ -112,38 +119,52 @@ export const ReactionShare: FC<{ })()}
- -
-
- -
-
-
    - {shareList.map(({ name, icon, onClick }) => ( -
  • onClick({ url, text, title })} - > - {icon} - {name} -
  • - ))} -
-
-
-
) } + +const usePresentShareModal = () => { + const { present } = useModalStack() + const { t } = useTranslation("common") + return (props: ShareModalProps) => { + present({ + title: t("Share Modal") || "", + content: () => , + }) + } +} +interface ShareModalProps { + url: string + title: string + text: string +} +const ShareModal: FC = ({ url, text, title }) => { + return ( +
+
+ +
+
+
    + {shareList.map(({ name, icon, onClick }) => ( +
  • onClick({ url, text, title })} + > + {icon} + {name} +
  • + ))} +
+
+
+ ) +} diff --git a/src/components/common/ReactionTip.tsx b/src/components/common/ReactionTip.tsx index 6cbcb8f1..63298e55 100644 --- a/src/components/common/ReactionTip.tsx +++ b/src/components/common/ReactionTip.tsx @@ -1,10 +1,10 @@ "use client" -import { useMemo, useRef, useState } from "react" +import { useMemo, useRef } from "react" import { useAccountState } from "@crossbell/connect-kit" -import { PatronModal } from "~/components/common/PatronModal" +import { usePatronModal } from "~/components/common/PatronModal" import { Tooltip } from "~/components/ui/Tooltip" import { useTranslation } from "~/lib/i18n/client" import { noopArr } from "~/lib/noop" @@ -32,7 +32,6 @@ export const ReactionTip = ({ const account = useAccountState((s) => s.computed.account) - const [isTipOpen, setIsTipOpen] = useState(false) const tipRef = useRef(null) const tips = useGetTips({ @@ -45,9 +44,11 @@ export const ReactionTip = ({ characterId: account?.characterId || "0", }) + const presentPatronModal = usePatronModal() + const tip = () => { if (characterId && noteId) { - setIsTipOpen(true) + presentPatronModal(site, page) } } @@ -111,12 +112,6 @@ export const ReactionTip = ({ /> )} - ) } diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index ca5ae832..5cb21962 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react" import { cn } from "~/lib/utils" -interface ModalProps { +export interface ModalProps { open: boolean setOpen: (open: boolean) => void children: React.ReactNode @@ -16,6 +16,7 @@ interface ModalProps { zIndex?: number panelClassName?: string boxClassName?: string + afterLeave?: () => void } export const Modal = forwardRef( @@ -30,11 +31,12 @@ export const Modal = forwardRef( zIndex, panelClassName, boxClassName, + afterLeave, }, ref, ) => { return ( - + setOpen(false)} className="relative" @@ -76,7 +78,7 @@ export const Modal = forwardRef( {/* The actual dialog panel */} ( {title} setOpen(false)} > diff --git a/src/components/ui/ModalStack.tsx b/src/components/ui/ModalStack.tsx new file mode 100644 index 00000000..e709da55 --- /dev/null +++ b/src/components/ui/ModalStack.tsx @@ -0,0 +1,165 @@ +/* eslint-disable react/prop-types */ + +/* eslint-disable react-hooks/exhaustive-deps */ +import type { FC, PropsWithChildren, ReactNode, SetStateAction } from "react" +import { + Dispatch, + createContext, + createElement, + memo, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, +} from "react" + +import { + Modal as ModalImpl, + ModalProps as ModalImplProps, +} from "~/components/ui/Modal" +import { useIsClient } from "~/hooks/useClient" + +const modalIdToPropsMap = {} as Record + +export type ModalContentProps = { dismiss: () => void } & T +interface ModalProps { + title: ReactNode + content: FC + + modalProps?: Partial> +} + +interface ModalInstance { + modalClose: () => void +} +interface ModalStackContextValue extends ModalProps { + id: string + ins?: ModalInstance +} +const ModalStackContext = createContext([]) +const SetModalStackContext = createContext< + Dispatch> +>(() => void 0) + +export const useModalStack = () => { + const id = useId() + const currentCount = useRef(0) + const setStack = useContext(SetModalStackContext) + return useMemo( + () => ({ + present(props: ModalProps & { id?: string }) { + const modalId = `${id}-${currentCount.current++}` + setStack((p) => { + const modalProps = { + ...props, + id: props.id ?? modalId, + } + modalIdToPropsMap[modalProps.id] = modalProps + return p.concat(modalProps) + }) + + return () => { + setStack((p) => { + return p.filter((item) => item.id !== modalId) + }) + } + }, + + dismiss(id: string) { + setStack((p) => { + const m = p.find((item) => item.id === id) + if (m?.ins) { + m.ins.modalClose() + return p + } + + return p.filter((item) => item.id !== id) + }) + }, + }), + [id, setStack], + ) +} + +export const ModalStackProvider: FC = ({ children }) => { + const [modal, setModal] = useState( + [] as React.ContextType, + ) + return ( + + {children} + + + + + ) +} + +const ModalStack = () => { + const stack = useContext(ModalStackContext) + + const isClient = useIsClient() + if (!isClient) return null + + return ( + <> + {stack.map((item, index) => { + return + })} + + ) +} +const Modal = memo<{ + item: ModalProps & { id: string } + index: number +}>(function Modal({ item, index }) { + const setStack = useContext(SetModalStackContext) + const close = useCallback(() => { + setStack((p) => { + return p.filter((modal) => modal.id !== item.id) + }) + }, [item.id]) + + const instanceRef = useRef({ + modalClose: () => { + setOpen(false) + }, + }) + + const { content, title, modalProps } = item + + const [open, setOpen] = useState(false) + useEffect(() => { + setOpen(true) + + // set instanceRef + + setStack((p) => { + const newStack = [...p] + newStack[index].ins = instanceRef.current + return newStack + }) + }, []) + const handleClose = useCallback(() => { + setOpen(false) + }, []) + const afterLeave = useCallback(() => { + close() + }, []) + return ( + + {createElement(content, { + dismiss: handleClose, + })} + + ) +}) diff --git a/src/components/ui/TagInput.tsx b/src/components/ui/TagInput.tsx index 3911f353..011c5ed7 100644 --- a/src/components/ui/TagInput.tsx +++ b/src/components/ui/TagInput.tsx @@ -5,7 +5,7 @@ import { shallow } from "zustand/shallow" import { Combobox, Transition } from "@headlessui/react" import { ChevronUpDownIcon, XMarkIcon } from "@heroicons/react/20/solid" -import { useEditorState } from "~/hooks/useEdtiorState" +import { useEditorState } from "~/hooks/useEditorState" import { cn } from "~/lib/utils" import { CustomInputProps } from "./Input" diff --git a/src/hooks/useEdtiorState.ts b/src/hooks/useEditorState.ts similarity index 100% rename from src/hooks/useEdtiorState.ts rename to src/hooks/useEditorState.ts