feat: introduce `ModalStack` component for declarative modal creation (#741)

This commit is contained in:
Innei 2023-07-07 23:40:05 +08:00 committed by GitHub
parent b1566fc059
commit 48aa67e784
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 638 additions and 332 deletions

View File

@ -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) => (
<PublishedModal
postUrl={getPostUrl()}
transactionUrl={getTransactionUrl()}
twitterShareUrl={getTwitterShareUrl_()}
{...props}
/>
),
})
setIsCheersOpen(true)
showConfetti()
createPage.reset()
@ -485,9 +519,7 @@ export default function SubdomainEditor() {
)
}, [draftKey, subdomain, site.data?.characterId])
const [isAdvancedOptionsOpen, setIsAdvancedOptionsOpen] =
useState<boolean>(false)
const presentAdvancedModal = useEditorAdvancedModal({ isPost })
const extraProperties = (
<EditorExtraProperties
defaultSlug={defaultSlug}
@ -495,7 +527,7 @@ export default function SubdomainEditor() {
isPost={isPost}
subdomain={subdomain}
userTags={userTags.data?.list || []}
openAdvancedOptions={() => 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}
/>
)}
</div>
</>
)}
</DashboardMain>
<EditorAdvancedOptions
updateValue={updateValue}
isAdvancedOptionsOpen={isAdvancedOptionsOpen}
setIsAdvancedOptionsOpen={setIsAdvancedOptionsOpen}
isPost={isPost}
/>
<Modal
open={isCheersOpen}
setOpen={setIsCheersOpen}
title={`🎉 ${t("Published!")}`}
>
<div className="p-5">
{t(
"Your post has been securely stored on the blockchain. Now you may want to",
)}
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>
<UniLink
className="text-accent"
href={`${getSiteLink({
subdomain,
domain: site.data?.metadata?.content?.custom_domain,
})}/${encodeURIComponent(values.slug || defaultSlug)}`}
>
{t("View the post")}
</UniLink>
</li>
<li>
<UniLink
className="text-accent"
href={`${CSB_SCAN}/tx/${
page.data?.updatedTransactionHash ||
page.data?.transactionHash
}`}
>
{t("View the transaction")}
</UniLink>
</li>
<li>
<UniLink
className="text-accent"
href={
page.data && site.data
? getTwitterShareUrl({
page: page.data,
site: site.data,
t,
})
: ""
}
>
{t("Share to Twitter")}
</UniLink>
</li>
</ul>
</div>
<div className="h-16 border-t flex items-center px-5">
<Button isBlock onClick={() => setIsCheersOpen(false)}>
{t("Got it, thanks!")}
</Button>
</div>
</Modal>
</>
)
}
@ -896,93 +866,139 @@ const EditorExtraProperties = memo(
EditorExtraProperties.displayName = "EditorExtraProperties"
const EditorAdvancedOptions = memo(
({
updateValue,
isAdvancedOptionsOpen,
setIsAdvancedOptionsOpen,
isPost,
}: {
updateValue: <K extends keyof Values>(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 (
<Modal
open={isAdvancedOptionsOpen}
setOpen={setIsAdvancedOptionsOpen}
title={t("Advanced Settings")}
>
<div className="p-5 space-y-5">
<div>
<label className="form-label">
{t("Disable AI-generated summary")}
</label>
<Switch
label=""
checked={values.disableAISummary}
setChecked={(state) => updateValue("disableAISummary", state)}
/>
</div>
<div>
<label className="form-label" htmlFor="publishAt">
{t("Publish at")}
</label>
<DateInput
className=""
allowDeselect
clearable
valueFormat="YYYY-MM-DD, h:mm a"
name="publishAt"
id="publishAt"
value={
values.publishedAt ? new Date(values.publishedAt) : undefined
}
onChange={(value: Date | null) => {
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)",
},
},
}}
/>
<div className="text-xs text-gray-400 mt-1">
{t(
`This ${
isPost ? "post" : "page"
} will be accessible from this time. Leave blank to use the current time.`,
)}
</div>
{values.publishedAt > new Date().toISOString() && (
<div className="text-xs mt-1 text-orange-500">
{t(
"The post is currently not public as its publication date has been scheduled for a future time.",
)}
</div>
return () => {
present({
title: t("Advanced Settings"),
content: () => <EditorAdvancedModal isPost={isPost} />,
})
}
}
const EditorAdvancedModal: FC<{
isPost: boolean
}> = ({ isPost }) => {
const { t } = useTranslation("dashboard")
const values = useEditorState(
(state) => pick(state, ["disableAISummary", "publishedAt"]),
shallow,
)
const updateValue = useEditorState.setState
return (
<div className="p-5 space-y-5">
<div>
<label className="form-label">
{t("Disable AI-generated summary")}
</label>
<Switch
label=""
checked={values.disableAISummary}
// setChecked={(state) => updateValue("disableAISummary", state)}
setChecked={(state) => {
updateValue({
disableAISummary: state,
})
}}
/>
</div>
<div>
<label className="form-label" htmlFor="publishAt">
{t("Publish at")}
</label>
<DateInput
className="[&_input]:bg-slate-50 [&_input]:text-black/90"
allowDeselect
clearable
valueFormat="YYYY-MM-DD, h:mm a"
name="publishAt"
id="publishAt"
value={values.publishedAt ? new Date(values.publishedAt) : undefined}
onChange={(value: Date | null) => {
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)",
},
},
}}
/>
<div className="text-xs text-gray-400 mt-1">
{t(
`This ${
isPost ? "post" : "page"
} will be accessible from this time. Leave blank to use the current time.`,
)}
</div>
{values.publishedAt > new Date().toISOString() && (
<div className="text-xs mt-1 text-orange-500">
{t(
"The post is currently not public as its publication date has been scheduled for a future time.",
)}
</div>
</div>
</Modal>
)
},
)
)}
</div>
</div>
)
}
EditorAdvancedOptions.displayName = "EditorAdvancedOptions"
const PublishedModal = ({
dismiss,
postUrl,
transactionUrl,
twitterShareUrl,
}: ModalContentProps<{
postUrl: string
transactionUrl: string
twitterShareUrl: string
}>) => {
const { t } = useTranslation("dashboard")
return (
<>
<div className="p-5">
{t(
"Your post has been securely stored on the blockchain. Now you may want to",
)}
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>
<UniLink className="text-accent" href={postUrl}>
{t("View the post")}
</UniLink>
</li>
<li>
<UniLink className="text-accent" href={transactionUrl}>
{t("View the transaction")}
</UniLink>
</li>
<li>
<UniLink className="text-accent" href={twitterShareUrl}>
{t("Share to Twitter")}
</UniLink>
</li>
</ul>
</div>
<div className="h-16 border-t flex items-center px-5">
<Button isBlock onClick={() => dismiss()}>
{t("Got it, thanks!")}
</Button>
</div>
</>
)
}

View File

@ -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}
>
<LangProvider lang={lang}>{children}</LangProvider>
<LangProvider lang={lang}>
<ModalStackProvider>{children}</ModalStackProvider>
</LangProvider>
<NotificationModal
colorScheme={colorScheme}
filter={filterNotificationCharacter}

View File

@ -1,8 +1,6 @@
"use client"
import { useState } from "react"
import { PatronModal } from "~/components/common/PatronModal"
import { usePatronModal } from "~/components/common/PatronModal"
import { Button } from "~/components/ui/Button"
import { useTranslation } from "~/lib/i18n/client"
import { ExpandedCharacter } from "~/lib/types"
@ -20,17 +18,17 @@ export const PatronButton = ({
loadingStatusChange?: (status: boolean) => void
}) => {
const { t } = useTranslation("common")
const [open, setOpen] = useState(false)
const presentPatronModal = usePatronModal()
return (
<>
<PatronModal open={open} setOpen={setOpen} site={site} />
<Button
variant="text"
aria-label={"Patron"}
key={t("Patron")}
className={cn(className, "-mx-2 text-red-400")}
onClick={() => setOpen(true)}
onClick={() => presentPatronModal(site)}
>
<span className="inline-flex items-center">
<i className="text-red-400 text-xl inline-flex items-center">

View File

@ -7,28 +7,54 @@ import { useConnectModal } from "@crossbell/connect-kit"
import { Avatar } from "~/components/ui/Avatar"
import { BoxRadio } from "~/components/ui/BoxRadio"
import { Button } from "~/components/ui/Button"
import { Modal } from "~/components/ui/Modal"
import { CSB_SCAN, MIRA_LINK } from "~/lib/env"
import { getSiteLink } from "~/lib/helpers"
import { useTranslation } from "~/lib/i18n/client"
import { ExpandedCharacter, ExpandedNote } from "~/lib/types"
import { useGetTips, useTipCharacter } from "~/queries/site"
import { ModalContentProps, useModalStack } from "../ui/ModalStack"
import { Tabs } from "../ui/Tabs"
import { UniLink } from "../ui/UniLink"
import { CharacterFloatCard } from "./CharacterFloatCard"
export const PatronModal = ({
export const usePatronModal = () => {
const { present } = useModalStack()
const { t } = useTranslation("common")
return (site?: ExpandedCharacter, page?: ExpandedNote) => {
const title =
(page
? t("Tip the post: {{name}}", {
name: page.metadata?.content?.title,
})
: t("Become a patron of {{name}}", {
name: site?.metadata?.content?.name,
})) || ""
present({
title: (
<span className="inline-flex items-center justify-center w-full space-x-1">
<span className="text-red-500 flex w-6 h-6 -mb-[1px]">
<i className="icon-[mingcute--heart-fill] text-2xl -mb-[1px]" />
</span>
<span className="truncate">{title}</span>
</span>
),
content: (props) => <PatronModal {...props} page={page} site={site} />,
modalProps: {
size: "lg",
},
})
}
}
const PatronModal = ({
site,
page,
open,
setOpen,
}: {
dismiss,
}: ModalContentProps<{
site?: ExpandedCharacter
page?: ExpandedNote
open: boolean
setOpen: (open: boolean) => void
}) => {
}>) => {
const { t } = useTranslation("common")
const tipCharacter = useTipCharacter()
const tips = useGetTips(
@ -79,7 +105,7 @@ export const PatronModal = ({
noteId: page?.noteId,
})
} else {
setOpen(false)
dismiss()
connectModal.show()
}
}
@ -124,19 +150,7 @@ export const PatronModal = ({
})) || ""
return (
<Modal
open={open}
setOpen={setOpen}
title={
<span className="inline-flex items-center justify-center w-full space-x-1">
<span className="text-red-500 flex w-6 h-6 -mb-[1px]">
<i className="icon-[mingcute--heart-fill] text-2xl -mb-[1px]" />
</span>
<span className="truncate">{title}</span>
</span>
}
size="lg"
>
<>
<div className="px-5 py-4 space-y-4 text-center">
<div className="space-y-1">
<span className="flex items-center justify-center">
@ -263,6 +277,6 @@ export const PatronModal = ({
</Button>
</div>
</div>
</Modal>
</>
)
}

View File

@ -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<HTMLButtonElement>(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) => (
<UnLikeModal
{...props}
characterId={characterId}
noteId={noteId}
likeStatus={likeStatus}
toggleLikePage={toggleLikePage}
/>
),
})
}
}
const like = () => {
if (characterId && noteId) {
if (likeStatus.isLiked) {
setIsLikeOpen(true)
present({
title: t("Like successfully") || "",
content: (props) => (
<LikeModal
{...props}
likeStatus={likeStatus}
presentUnlikeModal={presentUnlikeModal}
/>
),
})
} 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 = ({
/>
)}
</div>
<Modal
open={isLikeOpen}
setOpen={setIsLikeOpen}
title={t("Like successfully") || ""}
>
<div className="p-5">
<Trans i18nKey="like stored" i18n={i18n}>
Your like has been stored on the blockchain, view it on{" "}
<UniLink
className="text-accent"
href={`${CSB_SCAN}/tx/${likeStatus.transactionHash}`}
>
Crossbell Scan
</UniLink>
</Trans>
</div>
<div className="border-t flex flex-col md:flex-row gap-4 items-center px-5 py-4">
<Button isBlock onClick={() => setIsLikeOpen(false)}>
{t("Got it, thanks!")}
</Button>
<Button
variant="secondary"
isBlock
onClick={() => {
setIsUnlikeOpen(true)
setIsLikeOpen(false)
}}
>
{t("Revert")}
</Button>
</div>
</Modal>
<Modal
open={isUnlikeOpen}
setOpen={setIsUnlikeOpen}
title={t("Confirm to revert")}
>
<div className="p-5">
<Trans i18nKey="like revert" i18n={i18n}>
Do you really want to revert this like action?
</Trans>
</div>
<div className="border-t flex flex-col md:flex-row gap-4 items-center px-5 py-4">
<Button isBlock onClick={() => setIsUnlikeOpen(false)}>
{t("Cancel")}
</Button>
<Button variant="secondary" isBlock onClick={() => unlike()}>
{t("Confirm")}
</Button>
</div>
</Modal>
{showAvatarStack && (
<CharacterList
open={isLikeListOpen}
@ -231,3 +205,94 @@ export const ReactionLike = ({
</>
)
}
const LikeModal = ({
dismiss,
likeStatus,
presentUnlikeModal,
}: ModalContentProps<{
likeStatus: any
presentUnlikeModal: () => void
}>) => {
const { t, i18n } = useTranslation("common")
return (
<>
<div className="p-5">
<Trans i18nKey="like stored" i18n={i18n}>
Your like has been stored on the blockchain, view it on{" "}
<UniLink
className="text-accent"
href={`${CSB_SCAN}/tx/${likeStatus.transactionHash}`}
>
Crossbell Scan
</UniLink>
</Trans>
</div>
<div className="border-t flex flex-col md:flex-row gap-4 items-center px-5 py-4">
<Button isBlock onClick={dismiss}>
{t("Got it, thanks!")}
</Button>
<Button
variant="secondary"
isBlock
onClick={() => {
presentUnlikeModal()
dismiss()
}}
>
{t("Revert")}
</Button>
</div>
</>
)
}
const UnLikeModal = ({
dismiss,
characterId,
noteId,
likeStatus,
toggleLikePage,
}: ModalContentProps<{
characterId: number
noteId: number
likeStatus: any
toggleLikePage: ReturnType<typeof useToggleLikePage>
}>) => {
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 (
<>
<div className="p-5">
<Trans i18nKey="like revert" i18n={i18n}>
Do you really want to revert this like action?
</Trans>
</div>
<div className="border-t flex flex-col md:flex-row gap-4 items-center px-5 py-4">
<Button isBlock onClick={dismiss}>
{t("Cancel")}
</Button>
<Button variant="secondary" isBlock onClick={() => unlike()}>
{t("Confirm")}
</Button>
</div>
</>
)
}

View File

@ -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<HTMLButtonElement>(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 = ({
/>
)}
</div>
<Modal
open={isMintOpen}
setOpen={setIsMintOpen}
title={t("Mint successfully") || ""}
>
<div className="p-5">
<Trans i18nKey="mint stored" i18n={i18n}>
This post has been minted to NFT by you, view it on{" "}
<UniLink
className="text-accent"
href={`${CSB_XCHAR}/${account?.character?.handle}/collections`}
>
xChar
</UniLink>{" "}
or{" "}
<UniLink
className="text-accent"
href={`${CSB_SCAN}/tx/${isMint.data?.list?.[0]?.transactionHash}`}
>
Crossbell Scan
</UniLink>
</Trans>
</div>
<div className="h-16 border-t flex items-center px-5">
<Button isBlock onClick={() => setIsMintOpen(false)}>
{t("Got it, thanks!")}
</Button>
</div>
</Modal>
{showAvatarStack && (
<CharacterList
open={isMintListOpen}
@ -200,3 +174,55 @@ export const ReactionMint = ({
</>
)
}
const usePresentMintModal = (props: MintModalProps) => {
const { present } = useModalStack()
const { t, i18n } = useTranslation("common")
return () => {
present({
title: t("Mint successfully") || "",
content: (rest) => <MintModal {...rest} {...props} />,
})
}
}
interface MintModalProps {
handle: string
transactionHash: string
}
const MintModal: FC<ModalContentProps<MintModalProps>> = ({
handle,
dismiss,
transactionHash,
}) => {
const { t, i18n } = useTranslation("common")
return (
<div>
<div className="p-5">
<Trans i18nKey="mint stored" i18n={i18n}>
This post has been minted to NFT by you, view it on{" "}
<UniLink
className="text-accent"
href={`${CSB_XCHAR}/${handle}/collections`}
>
xChar
</UniLink>{" "}
or{" "}
<UniLink
className="text-accent"
href={`${CSB_SCAN}/tx/${transactionHash}`}
>
Crossbell Scan
</UniLink>
</Trans>
</div>
<div className="h-16 border-t flex items-center px-5">
<Button isBlock onClick={dismiss}>
{t("Got it, thanks!")}
</Button>
</div>
</div>
)
}

View File

@ -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 (
<>
<div className={cn("xlog-reactions-share flex items-center sm:mb-0")}>
@ -112,38 +119,52 @@ export const ReactionShare: FC<{
})()}
</Button>
</div>
<Modal
open={isShareOpen}
setOpen={setIsShareOpen}
title={t("Share Modal") || ""}
>
<div className="relative grid grid-cols-[200px_auto] gap-5 px-5 py-6">
<div className="qrcode inline-block min-h-[200px]">
<QRCodeSVG
value={url}
className="aspect-square w-[200px]"
height={200}
width={200}
/>
</div>
<div className="share-options flex flex-col gap-2">
<ul className="w-[200px] flex-col gap-2 [&>li]:flex [&>li]:items-center [&>li]:space-x-2">
{shareList.map(({ name, icon, onClick }) => (
<li
key={name}
className="flex cursor-pointer items-center space-x-2 rounded-md px-3 py-2 text-lg transition-colors hover:bg-gray-100 [&_img]:w-[1rem] [&_img]:h-[1rem]"
aria-label={`Share to ${name}`}
role="button"
onClick={() => onClick({ url, text, title })}
>
{icon}
<span>{name}</span>
</li>
))}
</ul>
</div>
</div>
</Modal>
</>
)
}
const usePresentShareModal = () => {
const { present } = useModalStack()
const { t } = useTranslation("common")
return (props: ShareModalProps) => {
present({
title: t("Share Modal") || "",
content: () => <ShareModal {...props} />,
})
}
}
interface ShareModalProps {
url: string
title: string
text: string
}
const ShareModal: FC<ShareModalProps> = ({ url, text, title }) => {
return (
<div className="relative grid grid-cols-[200px_auto] gap-5 px-5 py-6">
<div className="qrcode inline-block min-h-[200px]">
<QRCodeSVG
value={url}
className="aspect-square w-[200px]"
height={200}
width={200}
/>
</div>
<div className="share-options flex flex-col gap-2">
<ul className="w-[200px] flex-col gap-2 [&>li]:flex [&>li]:items-center [&>li]:space-x-2">
{shareList.map(({ name, icon, onClick }) => (
<li
key={name}
className="flex cursor-pointer items-center space-x-2 rounded-md px-3 py-2 text-lg transition-colors hover:bg-gray-100 [&_img]:w-[1rem] [&_img]:h-[1rem]"
aria-label={`Share to ${name}`}
role="button"
onClick={() => onClick({ url, text, title })}
>
{icon}
<span>{name}</span>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@ -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<HTMLButtonElement>(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 = ({
/>
)}
</div>
<PatronModal
site={site}
open={isTipOpen}
setOpen={setIsTipOpen}
page={page}
/>
</>
)
}

View File

@ -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<HTMLDivElement, ModalProps>(
@ -30,11 +31,12 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
zIndex,
panelClassName,
boxClassName,
afterLeave,
},
ref,
) => {
return (
<Transition appear show={open} as={Fragment}>
<Transition appear show={open} as={Fragment} afterLeave={afterLeave}>
<Dialog
onClose={() => setOpen(false)}
className="relative"
@ -76,7 +78,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
{/* The actual dialog panel */}
<Dialog.Panel
className={cn(
`mx-auto rounded-lg bg-white w-full shadow-modal max-h-full overflow-y-auto flex flex-col`,
`mx-auto rounded-lg bg-white w-full shadow-modal max-h-full flex flex-col`,
size === "md"
? `max-w-md`
: size === "lg"
@ -92,7 +94,7 @@ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
{title}
</span>
<span
className="absolute right-1 w-7 h-7 text-xl cursor-pointer bg-white flex items-center justify-center"
className="absolute right-4 w-7 h-7 text-xl cursor-pointer bg-white flex items-center justify-center"
onClick={() => setOpen(false)}
>
<i className="icon-[mingcute--close-line] inline-block" />

View File

@ -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<string, ModalProps>
export type ModalContentProps<T = {}> = { dismiss: () => void } & T
interface ModalProps {
title: ReactNode
content: FC<ModalContentProps>
modalProps?: Partial<Omit<ModalImplProps, "open" | "setOpen" | "afterLeave">>
}
interface ModalInstance {
modalClose: () => void
}
interface ModalStackContextValue extends ModalProps {
id: string
ins?: ModalInstance
}
const ModalStackContext = createContext<ModalStackContextValue[]>([])
const SetModalStackContext = createContext<
Dispatch<SetStateAction<ModalStackContextValue[]>>
>(() => 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<PropsWithChildren> = ({ children }) => {
const [modal, setModal] = useState(
[] as React.ContextType<typeof ModalStackContext>,
)
return (
<SetModalStackContext.Provider value={setModal}>
{children}
<ModalStackContext.Provider value={modal}>
<ModalStack />
</ModalStackContext.Provider>
</SetModalStackContext.Provider>
)
}
const ModalStack = () => {
const stack = useContext(ModalStackContext)
const isClient = useIsClient()
if (!isClient) return null
return (
<>
{stack.map((item, index) => {
return <Modal key={item.id} item={item} index={index} />
})}
</>
)
}
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<ModalInstance>({
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 (
<ModalImpl
open={open}
afterLeave={afterLeave}
setOpen={handleClose}
title={title}
{...modalProps}
>
{createElement(content, {
dismiss: handleClose,
})}
</ModalImpl>
)
})

View File

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