From 8f991900e8fd590a2f145f0174b1293fd440fbc1 Mon Sep 17 00:00:00 2001 From: Innei Date: Wed, 28 Jun 2023 17:05:23 +0800 Subject: [PATCH] feat: comment box Signed-off-by: Innei --- .../(post-detail)/[category]/[slug]/page.tsx | 3 + src/components/common/ToastCard.tsx | 2 +- .../comment/CommentBox/CommentAuthedInput.tsx | 17 +++- .../CommentBox/CommentBoxActionBar.tsx | 81 +++++++++++++++++-- .../comment/CommentBox/CommentBoxProvider.tsx | 29 ++++++- .../comment/CommentBox/CommentBoxRoot.tsx | 3 +- src/components/widgets/comment/Comments.tsx | 5 +- .../comment/{CommentBox => }/constants.ts | 0 src/lib/toast.ts | 45 ++++++++--- src/utils/request.ts | 22 ++++- 10 files changed, 178 insertions(+), 29 deletions(-) rename src/components/widgets/comment/{CommentBox => }/constants.ts (100%) diff --git a/src/app/posts/(post-detail)/[category]/[slug]/page.tsx b/src/app/posts/(post-detail)/[category]/[slug]/page.tsx index 4e0ab97bf9..0f7b0e8eb2 100644 --- a/src/app/posts/(post-detail)/[category]/[slug]/page.tsx +++ b/src/app/posts/(post-detail)/[category]/[slug]/page.tsx @@ -9,6 +9,7 @@ import { ClientOnly } from '~/components/common/ClientOnly' import { ReadIndicator } from '~/components/common/ReadIndicator' import { useSetHeaderMetaInfo } from '~/components/layout/header/hooks' import { Markdown } from '~/components/ui/markdown' +import { CommentAreaRoot } from '~/components/widgets/comment' import { PostActionAside } from '~/components/widgets/post/PostActionAside' import { PostCopyright } from '~/components/widgets/post/PostCopyright' import { PostMetaBar } from '~/components/widgets/post/PostMetaBar' @@ -70,6 +71,8 @@ const PostPage = () => { + + ) } diff --git a/src/components/common/ToastCard.tsx b/src/components/common/ToastCard.tsx index 9ef8fdeb2c..15f6f0611e 100644 --- a/src/components/common/ToastCard.tsx +++ b/src/components/common/ToastCard.tsx @@ -32,7 +32,7 @@ export const ToastCard: FC<{ layout="position" className={clsx( 'relative w-full overflow-hidden rounded-xl shadow-md shadow-slate-200 dark:shadow-stone-800', - 'my-4 mr-4 px-4 py-5', + 'my-4 mr-4 px-4 py-5 pr-7', 'bg-slate-50/90 backdrop-blur-sm dark:bg-neutral-900/90', 'border border-slate-100/80 dark:border-neutral-900/80', 'space-x-4', diff --git a/src/components/widgets/comment/CommentBox/CommentAuthedInput.tsx b/src/components/widgets/comment/CommentBox/CommentAuthedInput.tsx index 38db21a965..9ac9331bfe 100644 --- a/src/components/widgets/comment/CommentBox/CommentAuthedInput.tsx +++ b/src/components/widgets/comment/CommentBox/CommentAuthedInput.tsx @@ -1,21 +1,32 @@ 'use client' -import { useRef } from 'react' +import { useEffect, useRef } from 'react' import clsx from 'clsx' import Image from 'next/image' import { useUser } from '@clerk/nextjs' +import { getRandomPlaceholder } from '../constants' import { CommentAuthedInputSkeleton } from './CommentAuthedInputSkeleton' import { CommentBoxActionBar } from './CommentBoxActionBar' import { useCommentBoxTextValue, useSetCommentBoxValues, } from './CommentBoxProvider' -import { getRandomPlaceholder } from './constants' export const CommentAuthedInput = () => { const { user } = useUser() + const setter = useSetCommentBoxValues() + + useEffect(() => { + if (!user) return + setter( + 'author', + user.fullName || user.lastName || user.firstName || 'Anonymous', + ) + setter('avatar', user.profileImageUrl) + setter('mail', user.primaryEmailAddress?.emailAddress || '') + }, [user]) const TextArea = useRef(function Textarea() { const placeholder = useRef(getRandomPlaceholder()).current @@ -29,7 +40,7 @@ export const CommentAuthedInput = () => { }} placeholder={placeholder} className={clsx( - 'h-full w-full bg-transparent', + 'h-full w-full resize-none bg-transparent', 'overflow-auto px-3 py-4', 'text-neutral-900/80 dark:text-slate-100/80', )} diff --git a/src/components/widgets/comment/CommentBox/CommentBoxActionBar.tsx b/src/components/widgets/comment/CommentBox/CommentBoxActionBar.tsx index 50074beaea..f42adb9321 100644 --- a/src/components/widgets/comment/CommentBox/CommentBoxActionBar.tsx +++ b/src/components/widgets/comment/CommentBox/CommentBoxActionBar.tsx @@ -1,19 +1,33 @@ 'use client' +import { useMutation, useQueryClient } from '@tanstack/react-query' import clsx from 'clsx' import { AnimatePresence, motion } from 'framer-motion' +import { produce } from 'immer' +import type { + CommentModel, + PaginateResult, + RequestError, +} from '@mx-space/api-client' +import type { InfiniteData } from '@tanstack/react-query' +import { useIsLogged } from '~/atoms' import { TiltedSendIcon } from '~/components/icons/TiltedSendIcon' -import { FloatPopover } from '~/components/ui/float-popover' import { MLink } from '~/components/ui/markdown/renderers/link' +import { jotaiStore } from '~/lib/store' +import { toast } from '~/lib/toast' import { clsxm } from '~/utils/helper' +import { apiClient, getErrorMessageFromRequestError } from '~/utils/request' +import { buildQueryKey } from '../Comments' +import { MAX_COMMENT_TEXT_LENGTH } from '../constants' import { useCommentBoxHasText, + useCommentBoxRefIdValue, useCommentBoxTextIsOversize, useCommentBoxTextValue, + useGetCommentBoxAtomValues, } from './CommentBoxProvider' -import { MAX_COMMENT_TEXT_LENGTH } from './constants' const TextLengthIndicator = () => { const isTextOversize = useCommentBoxTextIsOversize() @@ -61,9 +75,7 @@ export const CommentBoxActionBar: Component = ({ className }) => { > - - 发送 - + )} @@ -72,11 +84,61 @@ export const CommentBoxActionBar: Component = ({ className }) => { } const SubmitButton = () => { - const isLoading = false - const onClickSend = () => {} + const commentRefId = useCommentBoxRefIdValue() + const { + text: textAtom, + author: authorAtom, + mail: mailAtom, + } = useGetCommentBoxAtomValues() + const isLogged = useIsLogged() + const queryClient = useQueryClient() + const { isLoading, mutate } = useMutation({ + mutationFn: async (refId: string) => { + const text = jotaiStore.get(textAtom) + const author = jotaiStore.get(authorAtom) + const mail = jotaiStore.get(mailAtom) + + if (isLogged) { + return apiClient.comment.proxy.master + .comment(refId) + .post({ + params: { + ts: Date.now(), + }, + data: { text }, + }) + } + return apiClient.comment.comment(refId, { text, author, mail }) + }, + mutationKey: [commentRefId, 'comment'], + onError(error: RequestError) { + toast.error(getErrorMessageFromRequestError(error)) + }, + onSuccess(data) { + toast.success('感谢你的评论!') + jotaiStore.set(textAtom, '') + queryClient.setQueryData< + InfiniteData< + PaginateResult< + CommentModel & { + ref: string + } + > + > + >(buildQueryKey(commentRefId), (oldData) => { + if (!oldData) return oldData + return produce(oldData, (draft) => { + draft.pages[0].data.unshift(data) + }) + }) + }, + }) + const onClickSend = () => { + mutate(commentRefId) + } return ( { onClick={onClickSend} > + + {isLoading ? '送信...' : '送信'} + ) } diff --git a/src/components/widgets/comment/CommentBox/CommentBoxProvider.tsx b/src/components/widgets/comment/CommentBox/CommentBoxProvider.tsx index 0f2a65535f..16dc7689d4 100644 --- a/src/components/widgets/comment/CommentBox/CommentBoxProvider.tsx +++ b/src/components/widgets/comment/CommentBox/CommentBoxProvider.tsx @@ -8,16 +8,33 @@ import type { PropsWithChildren } from 'react' import { jotaiStore } from '~/lib/store' -import { MAX_COMMENT_TEXT_LENGTH } from './constants' +import { MAX_COMMENT_TEXT_LENGTH } from '../constants' const createInitialValue = () => ({ - text: atom(''), refId: atom(''), + + text: atom(''), + author: atom(''), + mail: atom(''), + url: atom(''), + + avatar: atom(''), + source: atom(''), }) const CommentBoxContext = createContext(createInitialValue()) -export const CommentBoxProvider = (props: PropsWithChildren) => { +export const CommentBoxProvider = ( + props: PropsWithChildren & { refId: string }, +) => { return ( - + {props.children} ) @@ -29,6 +46,10 @@ export const useCommentBoxTextValue = () => export const useCommentBoxRefIdValue = () => useAtomValue(useContext(CommentBoxContext).refId) +export const useGetCommentBoxAtomValues = () => { + return useContext(CommentBoxContext) +} + export const useCommentBoxHasText = () => useAtomValue( selectAtom( diff --git a/src/components/widgets/comment/CommentBox/CommentBoxRoot.tsx b/src/components/widgets/comment/CommentBox/CommentBoxRoot.tsx index d5723c7902..d9f87760fa 100644 --- a/src/components/widgets/comment/CommentBox/CommentBoxRoot.tsx +++ b/src/components/widgets/comment/CommentBox/CommentBoxRoot.tsx @@ -18,9 +18,10 @@ const enum CommentBoxMode { } export const CommentBoxRoot: FC = (props) => { + const { refId } = props const [mode, setMode] = useState(CommentBoxMode['with-auth']) return ( - +
{mode === CommentBoxMode.legacy ? ( diff --git a/src/components/widgets/comment/Comments.tsx b/src/components/widgets/comment/Comments.tsx index 8fae3df74b..d09f077a62 100644 --- a/src/components/widgets/comment/Comments.tsx +++ b/src/components/widgets/comment/Comments.tsx @@ -1,6 +1,7 @@ 'use client' import { useInfiniteQuery } from '@tanstack/react-query' +import { useMemo } from 'react' import { useInView } from 'react-intersection-observer' import type { FC } from 'react' import type { CommentBaseProps } from './types' @@ -12,9 +13,11 @@ import { apiClient } from '~/utils/request' import { Comment } from './Comment' import { CommentSkeleton } from './CommentSkeleton' +export const buildQueryKey = (refId: string) => ['comments', refId] export const Comments: FC = ({ refId }) => { + const key = useMemo(() => buildQueryKey(refId), [refId]) const { data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery( - ['comments', refId], + key, async ({ queryKey, pageParam }) => { const page = pageParam // const { page } = meta as { page: number } diff --git a/src/components/widgets/comment/CommentBox/constants.ts b/src/components/widgets/comment/constants.ts similarity index 100% rename from src/components/widgets/comment/CommentBox/constants.ts rename to src/components/widgets/comment/constants.ts diff --git a/src/lib/toast.ts b/src/lib/toast.ts index 67fee54eff..0ed72c40f3 100644 --- a/src/lib/toast.ts +++ b/src/lib/toast.ts @@ -1,10 +1,38 @@ import { createElement } from 'react' import { toast as Toast } from 'react-toastify' -import type { ToastOptions, TypeOptions } from 'react-toastify' +import type { Id, ToastOptions, TypeOptions } from 'react-toastify' import { ToastCard } from '~/components/common/ToastCard' -export const toast = ( +const baseConfig = { + position: 'bottom-right', + autoClose: 3000, + pauseOnHover: true, + hideProgressBar: true, + + closeOnClick: true, + closeButton: false, +} satisfies ToastOptions + +interface ToastCustom { + ( + message: string, + type?: TypeOptions, + options?: ToastOptions & { + iconElement?: JSX.Element + }, + ): Id +} + +interface ToastCustom { + success(message: string, options?: ToastOptions): Id + info(message: string, options?: ToastOptions): Id + warn(message: string, options?: ToastOptions): Id + error(message: string, options?: ToastOptions): Id +} + +// @ts-ignore +export const toast: ToastCustom = ( message: string, type?: TypeOptions, options?: ToastOptions & { @@ -14,13 +42,12 @@ export const toast = ( const { iconElement, ...rest } = options || {} return Toast(createElement(ToastCard, { message, iconElement }), { type, - position: 'bottom-right', - autoClose: 3000, - pauseOnHover: true, - hideProgressBar: true, - - closeOnClick: true, - closeButton: false, + ...baseConfig, ...rest, }) } +;['success', 'info', 'warn', 'error'].forEach((type) => { + // @ts-ignore + toast[type] = (message: string, options?: ToastOptions) => + toast(message, type as TypeOptions, options) +}) diff --git a/src/utils/request.ts b/src/utils/request.ts index 213df47b38..7c6421b688 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,6 +1,10 @@ -import type { AxiosInstance } from 'axios' +import type { AxiosError, AxiosInstance } from 'axios' -import { allControllers, createClient } from '@mx-space/api-client' +import { + allControllers, + createClient, + RequestError, +} from '@mx-space/api-client' import { axiosAdaptor } from '@mx-space/api-client/dist/adaptors/axios' import { API_URL } from '~/constants/env' @@ -46,3 +50,17 @@ $axios.interceptors.request.use((config) => { return config }) + +export const getErrorMessageFromRequestError = (error: RequestError) => { + if (!(error instanceof RequestError)) return (error as Error).message + const axiosError = error.raw as AxiosError + const messagesOrMessage = (axiosError.response?.data as any)?.message + const bizMessage = + typeof messagesOrMessage === 'string' + ? messagesOrMessage + : Array.isArray(messagesOrMessage) + ? messagesOrMessage[0] + : undefined + + return bizMessage || axiosError.message +}