diff --git a/src/components/widgets/comment/Comment.module.css b/src/components/modules/comment/Comment.module.css
similarity index 100%
rename from src/components/widgets/comment/Comment.module.css
rename to src/components/modules/comment/Comment.module.css
diff --git a/src/components/widgets/comment/Comment.tsx b/src/components/modules/comment/Comment.tsx
similarity index 100%
rename from src/components/widgets/comment/Comment.tsx
rename to src/components/modules/comment/Comment.tsx
diff --git a/src/components/widgets/comment/CommentBox/ActionBar.tsx b/src/components/modules/comment/CommentBox/ActionBar.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/ActionBar.tsx
rename to src/components/modules/comment/CommentBox/ActionBar.tsx
diff --git a/src/components/widgets/comment/CommentBox/AuthedInput.tsx b/src/components/modules/comment/CommentBox/AuthedInput.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/AuthedInput.tsx
rename to src/components/modules/comment/CommentBox/AuthedInput.tsx
diff --git a/src/components/widgets/comment/CommentBox/AuthedInputSkeleton.tsx b/src/components/modules/comment/CommentBox/AuthedInputSkeleton.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/AuthedInputSkeleton.tsx
rename to src/components/modules/comment/CommentBox/AuthedInputSkeleton.tsx
diff --git a/src/components/widgets/comment/CommentBox/CommentBoxLegacyForm.tsx b/src/components/modules/comment/CommentBox/CommentBoxLegacyForm.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/CommentBoxLegacyForm.tsx
rename to src/components/modules/comment/CommentBox/CommentBoxLegacyForm.tsx
diff --git a/src/components/widgets/comment/CommentBox/Root.tsx b/src/components/modules/comment/CommentBox/Root.tsx
similarity index 96%
rename from src/components/widgets/comment/CommentBox/Root.tsx
rename to src/components/modules/comment/CommentBox/Root.tsx
index 23340ecb91..e0e73f247a 100644
--- a/src/components/widgets/comment/CommentBox/Root.tsx
+++ b/src/components/modules/comment/CommentBox/Root.tsx
@@ -7,7 +7,7 @@ import { SignedIn, SignedOut } from '@clerk/nextjs'
import { useIsLogged } from '~/atoms'
import { ErrorBoundary } from '~/components/common/ErrorBoundary'
-import { AutoResizeHeight } from '~/components/widgets/shared/AutoResizeHeight'
+import { AutoResizeHeight } from '~/components/modules/shared/AutoResizeHeight'
import { clsxm } from '~/lib/helper'
import { CommentBoxAuthedInput } from './AuthedInput'
diff --git a/src/components/widgets/comment/CommentBox/SignedOutContent.tsx b/src/components/modules/comment/CommentBox/SignedOutContent.tsx
similarity index 94%
rename from src/components/widgets/comment/CommentBox/SignedOutContent.tsx
rename to src/components/modules/comment/CommentBox/SignedOutContent.tsx
index d63ec7b762..b12bdedb8f 100644
--- a/src/components/widgets/comment/CommentBox/SignedOutContent.tsx
+++ b/src/components/modules/comment/CommentBox/SignedOutContent.tsx
@@ -6,8 +6,8 @@ import { SignInButton } from '@clerk/nextjs'
import { UserArrowLeftIcon } from '~/components/icons/user-arrow-left'
import { StyledButton } from '~/components/ui/button'
+import { useModalStack } from '~/components/ui/modal'
import { urlBuilder } from '~/lib/url-builder'
-import { useModalStack } from '~/providers/root/modal-stack-provider'
import { CommentBoxMode, setCommentMode } from './hooks'
diff --git a/src/components/widgets/comment/CommentBox/SwitchCommentMode.tsx b/src/components/modules/comment/CommentBox/SwitchCommentMode.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/SwitchCommentMode.tsx
rename to src/components/modules/comment/CommentBox/SwitchCommentMode.tsx
diff --git a/src/components/widgets/comment/CommentBox/UniversalTextArea.tsx b/src/components/modules/comment/CommentBox/UniversalTextArea.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/UniversalTextArea.tsx
rename to src/components/modules/comment/CommentBox/UniversalTextArea.tsx
diff --git a/src/components/widgets/comment/CommentBox/constants.ts b/src/components/modules/comment/CommentBox/constants.ts
similarity index 100%
rename from src/components/widgets/comment/CommentBox/constants.ts
rename to src/components/modules/comment/CommentBox/constants.ts
diff --git a/src/components/widgets/comment/CommentBox/hooks.tsx b/src/components/modules/comment/CommentBox/hooks.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/hooks.tsx
rename to src/components/modules/comment/CommentBox/hooks.tsx
diff --git a/src/components/widgets/comment/CommentBox/index.ts b/src/components/modules/comment/CommentBox/index.ts
similarity index 100%
rename from src/components/widgets/comment/CommentBox/index.ts
rename to src/components/modules/comment/CommentBox/index.ts
diff --git a/src/components/widgets/comment/CommentBox/providers.tsx b/src/components/modules/comment/CommentBox/providers.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentBox/providers.tsx
rename to src/components/modules/comment/CommentBox/providers.tsx
diff --git a/src/components/widgets/comment/CommentPinButton.tsx b/src/components/modules/comment/CommentPinButton.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentPinButton.tsx
rename to src/components/modules/comment/CommentPinButton.tsx
diff --git a/src/components/widgets/comment/CommentReplyButton.tsx b/src/components/modules/comment/CommentReplyButton.tsx
similarity index 96%
rename from src/components/widgets/comment/CommentReplyButton.tsx
rename to src/components/modules/comment/CommentReplyButton.tsx
index 4f9d207731..deb15c61e5 100644
--- a/src/components/widgets/comment/CommentReplyButton.tsx
+++ b/src/components/modules/comment/CommentReplyButton.tsx
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
import clsx from 'clsx'
import type { FC } from 'react'
-import { AutoResizeHeight } from '~/components/widgets/shared/AutoResizeHeight'
+import { AutoResizeHeight } from '~/components/modules/shared/AutoResizeHeight'
import { CommentBoxRootLazy } from '.'
import { CommentBoxHolderPortal } from './Comment'
diff --git a/src/components/widgets/comment/CommentRoot.tsx b/src/components/modules/comment/CommentRoot.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentRoot.tsx
rename to src/components/modules/comment/CommentRoot.tsx
diff --git a/src/components/widgets/comment/CommentRootLazy.tsx b/src/components/modules/comment/CommentRootLazy.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentRootLazy.tsx
rename to src/components/modules/comment/CommentRootLazy.tsx
diff --git a/src/components/widgets/comment/CommentSkeleton.tsx b/src/components/modules/comment/CommentSkeleton.tsx
similarity index 100%
rename from src/components/widgets/comment/CommentSkeleton.tsx
rename to src/components/modules/comment/CommentSkeleton.tsx
diff --git a/src/components/widgets/comment/Comments.tsx b/src/components/modules/comment/Comments.tsx
similarity index 100%
rename from src/components/widgets/comment/Comments.tsx
rename to src/components/modules/comment/Comments.tsx
diff --git a/src/components/widgets/comment/index.ts b/src/components/modules/comment/index.ts
similarity index 100%
rename from src/components/widgets/comment/index.ts
rename to src/components/modules/comment/index.ts
diff --git a/src/components/widgets/comment/types.ts b/src/components/modules/comment/types.ts
similarity index 100%
rename from src/components/widgets/comment/types.ts
rename to src/components/modules/comment/types.ts
diff --git a/src/components/modules/dashboard/comments/CommentAction.tsx b/src/components/modules/dashboard/comments/CommentAction.tsx
new file mode 100644
index 0000000000..62bec362ce
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentAction.tsx
@@ -0,0 +1,94 @@
+import { useContext } from 'react'
+import type { CommentModel } from '@mx-space/api-client'
+
+import { CommentState } from '@mx-space/api-client'
+
+import { MotionButtonBase } from '~/components/ui/button'
+import { useModalStack } from '~/components/ui/modal'
+import {
+ useDeleteCommentMutation,
+ useUpdateCommentStateMutation,
+} from '~/queries/definition/comment'
+
+import { DeleteConfirmButton } from '../../shared/DeleteConfirmButton'
+import {
+ CommentStateContext,
+ useSetCommentSelectionKeys,
+} from './CommentContext'
+import { ReplyModal } from './ReplyModal'
+
+export const CommentAction = (props: { comment: CommentModel }) => {
+ const currentState = useContext(CommentStateContext)
+
+ const { id, author } = props.comment
+
+ const setSelectionKeys = useSetCommentSelectionKeys()
+
+ const omitCurrentId = () => {
+ setSelectionKeys((keys) => {
+ const set = new Set(keys)
+ set.delete(id)
+ return set
+ })
+ }
+
+ const { present } = useModalStack()
+
+ const { mutateAsync: updateCommentState } = useUpdateCommentStateMutation({
+ onSuccess: () => {
+ omitCurrentId()
+ },
+ })
+
+ const { mutateAsync: deleteComment } = useDeleteCommentMutation({
+ onSuccess: () => {
+ omitCurrentId()
+ },
+ })
+
+ return (
+
+ {currentState === CommentState.Unread && (
+ {
+ updateCommentState({ id, state: CommentState.Read })
+ }}
+ >
+ 已读
+
+ )}
+ {
+ present({
+ title: `回复 ${author}`,
+ content: () => ,
+ clickOutsideToDismiss: false,
+ })
+ }}
+ >
+ 回复
+
+ {currentState === CommentState.Unread && (
+ {
+ updateCommentState({ id, state: CommentState.Junk })
+ }}
+ >
+ 标记垃圾
+
+ )}
+ {currentState !== CommentState.Unread && (
+ {
+ deleteComment({
+ id,
+ })
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/src/components/modules/dashboard/comments/CommentAuthorCell.tsx b/src/components/modules/dashboard/comments/CommentAuthorCell.tsx
new file mode 100644
index 0000000000..4c4cd6feba
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentAuthorCell.tsx
@@ -0,0 +1,45 @@
+import type { CommentModel } from '@mx-space/api-client'
+
+import { Avatar } from '~/components/ui/avatar'
+import { clsxm } from '~/lib/helper'
+
+import { OcticonGistSecret } from '../../comment/CommentPinButton'
+import { IpInfoPopover } from '../ip'
+import { CommentUrlRender } from './UrlRender'
+
+export const CommentAuthorCell: Component<{
+ comment: CommentModel
+}> = (props) => {
+ const { comment, className } = props
+ const { author, avatar, url, mail, ip, isWhispers } = comment
+ return (
+
+
+
+
+
+ {isWhispers && }
+
+
+
+ {mail}
+
+
+ {ip && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/comments/CommentBatchActionGroup.tsx b/src/components/modules/dashboard/comments/CommentBatchActionGroup.tsx
new file mode 100644
index 0000000000..4550a60041
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentBatchActionGroup.tsx
@@ -0,0 +1,124 @@
+import { useSearchParams } from 'next/navigation'
+
+import { CommentState } from '@mx-space/api-client'
+
+import { RoundedIconButton, StyledButton } from '~/components/ui/button'
+import { FloatPopover } from '~/components/ui/float-popover'
+import { useModalStack } from '~/components/ui/modal/stacked/provider'
+import { toast } from '~/lib/toast'
+import {
+ useDeleteCommentMutation,
+ useUpdateCommentStateMutation,
+} from '~/queries/definition/comment'
+
+import { OffsetHeaderLayout } from '../layouts'
+import {
+ useCommentSelectionKeys,
+ useSetCommentSelectionKeys,
+} from './CommentContext'
+
+export const CommentBatchActionGroup = () => {
+ const selectionKeys = useCommentSelectionKeys()
+ const setSelectionKeys = useSetCommentSelectionKeys()
+
+ const { mutateAsync: updateCommentState } = useUpdateCommentStateMutation()
+ const { mutateAsync: deleteCommentState } = useDeleteCommentMutation()
+
+ const batchChangeState = async (newState: CommentState) => {
+ const ids = Array.from(selectionKeys)
+
+ Promise.all(
+ ids.map((id) => updateCommentState({ id, state: newState })),
+ ).then(() => {
+ setSelectionKeys(new Set())
+ toast.success('操作已经成功')
+ })
+ }
+ const batchDelete = async () => {
+ const ids = Array.from(selectionKeys)
+
+ Promise.all(ids.map((id) => deleteCommentState({ id }))).then(() => {
+ setSelectionKeys(new Set())
+ toast.success('操作已经成功')
+ })
+ }
+ const search = useSearchParams()
+ const tab =
+ (parseInt(search.get('tab')!) as any as CommentState) || CommentState.Unread
+
+ const { present } = useModalStack()
+
+ if (!selectionKeys.size) return null
+ return (
+
+ {tab !== CommentState.Read && (
+ (
+ {
+ batchChangeState(CommentState.Read)
+ }}
+ >
+
+
+ )}
+ >
+ 已读
+
+ )}
+
+ {tab !== CommentState.Junk && (
+ (
+ {
+ batchChangeState(CommentState.Junk)
+ }}
+ >
+
+
+ )}
+ >
+ 垃圾
+
+ )}
+ (
+ {
+ present({
+ title: `删除 ${selectionKeys.size} 条评论`,
+ content: ({ dismiss }) => {
+ return (
+
+ {
+ batchDelete()
+ dismiss()
+ }}
+ >
+ 删除
+
+
+ )
+ },
+ })
+ }}
+ >
+
+
+ )}
+ >
+ 删除
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/comments/CommentContentCell.tsx b/src/components/modules/dashboard/comments/CommentContentCell.tsx
new file mode 100644
index 0000000000..6cfbefd197
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentContentCell.tsx
@@ -0,0 +1,77 @@
+import { useContext, useMemo } from 'react'
+import type { CommentModel } from '@mx-space/api-client'
+
+import { CollectionRefTypes } from '@mx-space/api-client'
+
+import { MotionButtonBase } from '~/components/ui/button'
+import { RelativeTime } from '~/components/ui/relative-time'
+import { EllipsisHorizontalTextWithTooltip } from '~/components/ui/typography'
+import { clsxm } from '~/lib/helper'
+import { apiClient } from '~/lib/request'
+
+import { CommentAction } from './CommentAction'
+import { CommentDataContext } from './CommentContext'
+import { CommentUrlRender } from './UrlRender'
+
+export const CommentContentCell: Component<{ comment: CommentModel }> = (
+ props,
+) => {
+ const { comment, className } = props
+ const {
+ created,
+ refType,
+ text,
+ id,
+ parent: parentComment,
+
+ isWhispers,
+ } = comment
+ const ctx = useContext(CommentDataContext)
+ const ref = ctx.refModelMap.get(id)
+
+ const TitleEl = useMemo(() => {
+ if (!ref) return
已删除
+ if (refType === CollectionRefTypes.Recently)
+ return `${ref.text.slice(0, 20)}...`
+ return (
+
{
+ const url = await apiClient.proxy.helper('url-builder')(ref.id).get<{
+ data: string
+ }>()
+ window.open(url?.data, '_blank')
+ }}
+ >
+
+ {ref.title}
+
+
+ )
+ }, [ref, refType])
+ return (
+
+
+ 于 {TitleEl} {isWhispers && '悄悄说'}
+
+
+
{text}
+
+ {parentComment && typeof parentComment !== 'string' && (
+
+
+
+ {' '}
+ 在 说:
+
+ {parentComment.text}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/comments/CommentContext.tsx b/src/components/modules/dashboard/comments/CommentContext.tsx
new file mode 100644
index 0000000000..0bf0164353
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentContext.tsx
@@ -0,0 +1,29 @@
+import { createContext, useContext } from 'react'
+import { createContextState } from 'foxact/create-context-state'
+import type {
+ CommentModel,
+ CommentState,
+ NoteModel,
+ PaginateResult,
+ PostModel,
+} from '@mx-space/api-client'
+import type { InfiniteData } from '@tanstack/react-query'
+
+export const CommentStateContext = createContext
(null!)
+interface CommentDataSourceContextType {
+ isLoading: boolean
+ data?: InfiniteData>
+}
+
+export const CommentDataSourceContext =
+ createContext(null!)
+export const CommentDataContext = createContext<{
+ refModelMap: Map
+}>(null!)
+export const useCommentDataSource = () => useContext(CommentDataSourceContext)
+
+export const [
+ CommentSelectionKeysProvider,
+ useCommentSelectionKeys,
+ useSetCommentSelectionKeys,
+] = createContextState(new Set())
diff --git a/src/components/modules/dashboard/comments/CommentDesktopTable.tsx b/src/components/modules/dashboard/comments/CommentDesktopTable.tsx
new file mode 100644
index 0000000000..ce7b8a1fa5
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentDesktopTable.tsx
@@ -0,0 +1,72 @@
+import { memo } from 'react'
+import type { CommentModel } from '@mx-space/api-client'
+import type { FC } from 'react'
+
+import { PageLoading } from '~/components/layout/dashboard/PageLoading'
+
+import { Empty } from '../../shared/Empty'
+import { CommentAuthorCell } from './CommentAuthorCell'
+import { CommentContentCell } from './CommentContentCell'
+import {
+ useCommentDataSource,
+ useCommentSelectionKeys,
+ useSetCommentSelectionKeys,
+} from './CommentContext'
+
+export const CommentDesktopTable = () => {
+ const { data, isLoading } = useCommentDataSource()
+
+ if (isLoading) {
+ return
+ }
+
+ const flatData = data?.pages.flatMap((page) => page.data)
+
+ if (!flatData?.length) return
+ return (
+
+ {flatData?.map((item) => {
+ return
+ })}
+
+ )
+}
+
+const CommentCheckBox: FC<{
+ id: string
+}> = ({ id }) => {
+ const selectionKeys = useCommentSelectionKeys()
+ const setSelectionKeys = useSetCommentSelectionKeys()
+
+ return (
+ {
+ if (e.target.checked) {
+ setSelectionKeys((prev) => new Set([...prev, id]))
+ return
+ }
+ setSelectionKeys((prev) => {
+ const next = new Set(prev)
+ next.delete(id)
+ return next
+ })
+ }}
+ type="checkbox"
+ className="checkbox-accent checkbox checkbox-md"
+ />
+ )
+}
+const CommentItem = ({ comment }: { comment: CommentModel }) => {
+ return (
+
+ )
+}
+const MemoCommentItem = memo(CommentItem)
diff --git a/src/components/modules/dashboard/comments/CommentMobileList.tsx b/src/components/modules/dashboard/comments/CommentMobileList.tsx
new file mode 100644
index 0000000000..c017998ecb
--- /dev/null
+++ b/src/components/modules/dashboard/comments/CommentMobileList.tsx
@@ -0,0 +1,56 @@
+import { Divider } from '~/components/ui/divider'
+import { AbsoluteCenterSpinner, Spinner } from '~/components/ui/spinner'
+import { isUndefined } from '~/lib/_'
+import { clsxm } from '~/lib/helper'
+
+import { Empty } from '../../shared/Empty'
+import { OffsetMainLayout } from '../layouts'
+import { CommentAuthorCell } from './CommentAuthorCell'
+import { CommentContentCell } from './CommentContentCell'
+import { useCommentDataSource } from './CommentContext'
+
+export const CommentMobileList = () => {
+ const { isLoading, data } = useCommentDataSource()
+
+ if (isLoading && isUndefined(data)) {
+ return (
+
+
+
+ )
+ }
+ if (!isUndefined(data) && !data.pages.length) {
+ return
+ }
+
+ const totalLength =
+ data?.pages.reduce((acc, page) => {
+ return acc + page.data.length
+ }, 0) || 0
+
+ return (
+
+ {isLoading && }
+
+ {data?.pages.map((page, i) => {
+ return page.data.map((item, j) => {
+ const idx = i * page.data.length + j
+ return (
+ -
+
+
+
+ {idx !== totalLength - 1 && }
+
+ )
+ })
+ })}
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/comments/ReplyModal.tsx b/src/components/modules/dashboard/comments/ReplyModal.tsx
new file mode 100644
index 0000000000..66e4c85952
--- /dev/null
+++ b/src/components/modules/dashboard/comments/ReplyModal.tsx
@@ -0,0 +1,194 @@
+import { useEffect, useState } from 'react'
+import clsx from 'clsx'
+import { atom, useStore } from 'jotai'
+import markdownEscape from 'markdown-escape'
+import type { CommentModel } from '@mx-space/api-client'
+
+import { useIsMobile } from '~/atoms'
+import { MotionButtonBase, StyledButton } from '~/components/ui/button'
+import { FloatPopover } from '~/components/ui/float-popover'
+import { TextArea } from '~/components/ui/input'
+import { Label } from '~/components/ui/label'
+import { useCurrentModal } from '~/components/ui/modal'
+import { ScrollArea } from '~/components/ui/scroll-area'
+import { PresentSheet } from '~/components/ui/sheet'
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { useUncontrolledInput } from '~/hooks/common/use-uncontrolled-input'
+import { toast } from '~/lib/toast'
+import { useReplyCommentMutation } from '~/queries/definition/comment'
+
+import { KAOMOJI_LIST } from './kaomoji'
+
+const replyTextAtom = atom('')
+export const ReplyModal = (props: { comment: CommentModel }) => {
+ const { author, id, text } = props.comment
+
+ const store = useStore()
+ const [, getValue, ref] = useUncontrolledInput(
+ store.get(replyTextAtom),
+ )
+
+ const { dismiss, ref: modalElRef } = useCurrentModal()
+
+ const { mutateAsync: reply } = useReplyCommentMutation()
+ const handleReply = useEventCallback(async () => {
+ const text = getValue()
+ if (!text) {
+ toast.error('回复内容不能为空')
+ return
+ }
+
+ reply({
+ id,
+ content: text,
+ })
+
+ dismiss()
+
+ store.set(replyTextAtom, '')
+ })
+
+ const handleSubmit = useEventCallback((e: any) => {
+ e.preventDefault()
+ })
+
+ const handleKeyDown = useEventCallback((e: React.KeyboardEvent) => {
+ // cmd + enter / ctrl + enter
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ handleReply()
+ }
+ })
+
+ const KaomojiContentEl = (
+
+ {
+ e.stopPropagation()
+ }}
+ >
+
+ {KAOMOJI_LIST.map((kamoji) => {
+ return (
+ {
+ const $ta = ref.current!
+ $ta.focus()
+
+ requestAnimationFrame(() => {
+ const start = $ta.selectionStart as number
+ const end = $ta.selectionEnd as number
+ const escapeKaomoji = markdownEscape(kamoji)
+ $ta.value = `${$ta.value.substring(
+ 0,
+ start,
+ )} ${escapeKaomoji} ${$ta.value.substring(
+ end,
+ $ta.value.length,
+ )}`
+
+ requestAnimationFrame(() => {
+ const shouldMoveToPos = start + escapeKaomoji.length + 2
+ $ta.selectionStart = shouldMoveToPos
+ $ta.selectionEnd = shouldMoveToPos
+
+ $ta.focus()
+ })
+ })
+ }}
+ >
+ {kamoji}
+
+ )
+ })}
+
+
+
+
+ )
+
+ const [kaomojiPanelOpen, setKaomojiPanelOpen] = useState(false)
+ const KaomojiButton = (
+
+
+
+ )
+ const isMobile = useIsMobile()
+
+ useEffect(() => {
+ const $ = ref.current
+ return () => {
+ if (!$) return
+ store.set(replyTextAtom, $.value)
+ }
+ }, [store, ref])
+
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/comments/UrlRender.tsx b/src/components/modules/dashboard/comments/UrlRender.tsx
new file mode 100644
index 0000000000..b5c6436d70
--- /dev/null
+++ b/src/components/modules/dashboard/comments/UrlRender.tsx
@@ -0,0 +1,15 @@
+export const CommentUrlRender = ({
+ url,
+ author,
+}: {
+ url: string | null | undefined
+ author: string
+}) => {
+ return url ? (
+
+ {author}
+
+ ) : (
+ {author}
+ )
+}
diff --git a/src/components/modules/dashboard/comments/index.ts b/src/components/modules/dashboard/comments/index.ts
new file mode 100644
index 0000000000..2b80cdb0ca
--- /dev/null
+++ b/src/components/modules/dashboard/comments/index.ts
@@ -0,0 +1,8 @@
+export * from './CommentAction'
+export * from './CommentAuthorCell'
+export * from './CommentBatchActionGroup'
+export * from './CommentContentCell'
+export * from './CommentContext'
+export * from './CommentDesktopTable'
+export * from './CommentMobileList'
+export * from './ReplyModal'
diff --git a/src/components/modules/dashboard/comments/kaomoji.ts b/src/components/modules/dashboard/comments/kaomoji.ts
new file mode 100644
index 0000000000..4696558ed4
--- /dev/null
+++ b/src/components/modules/dashboard/comments/kaomoji.ts
@@ -0,0 +1,346 @@
+export const KAOMOJI_LIST = [
+ '(o^▽^o)',
+ '(⌒▽⌒)☆',
+ '<( ̄︶ ̄)>',
+ 'ヽ(・∀・)ノ',
+ '( ̄ω ̄)',
+ '(o・ω・o)',
+ '(@^◡^)',
+ '(^人^)',
+ '(o´▽`o)',
+ '(*´▽`*)',
+ '(≧◡≦)',
+ '(o´∀`o)',
+ '(^▽^)',
+ '(⌒ω⌒)',
+ '╰(▔∀▔)╯',
+ '(─‿‿─)',
+ '(*^‿^*)',
+ '(✯◡✯)',
+ '(◕‿◕)',
+ '(*≧ω≦*)',
+ '(☆▽☆)',
+ '(⌒‿⌒)',
+ '\(≧▽≦)/',
+ '(*°▽°*)',
+ '(✧ω✧)',
+ '( ̄▽ ̄)',
+ 'o(≧▽≦)o',
+ '(☆ω☆)',
+ '\( ̄▽ ̄)/',
+ '(*¯︶¯*)',
+ '\(^▽^)/',
+ '٩(◕‿◕)۶',
+ '(o˘◡˘o)',
+ '\\(★ω★)/',
+ '\\(^ヮ^)/',
+ '(〃^▽^〃)',
+ '(╯✧▽✧)╯',
+ 'o(>ω<)o',
+ '(๑˃ᴗ˂)ﻭ',
+ '(๑˘︶˘๑)',
+ '(⁀ᗢ⁀)',
+ '(¬‿¬ )',
+ '(¬‿¬ )',
+ '(* ̄▽ ̄)b',
+ '( ˙▿˙ )',
+ '(¯▿¯)',
+ '( ◕▿◕ )',
+ '(ᵔ◡ᵔ)',
+ '(♡μ_μ)',
+ '(*^^*)♡',
+ '(♡-_-♡)',
+ '( ̄ε ̄@)',
+ 'ヽ(♡‿♡)ノ',
+ '(─‿‿─)♡',
+ '(*♡∀♡)',
+ '(◕‿◕)♡',
+ '(ღ˘⌣˘ღ)',
+ '(♡°▽°♡)',
+ '(♡˙︶˙♡)',
+ '(≧◡≦) ♡',
+ '(⌒▽⌒)♡',
+ '٩(♡ε♡)۶',
+ '♡ ( ̄З ̄)',
+ '(❤ω❤)',
+ '(´♡‿♡`)',
+ '(°◡°♡)',
+ '(´꒳`)♡',
+ '♡(>ᴗ•)',
+ '(⌒_⌒;)',
+ '(o^ ^o)',
+ '(*/ω\)',
+ '(*/。\)',
+ '(*/_\)',
+ '(*ノωノ)',
+ '(o-_-o)',
+ '(*μ_μ)',
+ '(ᵔ.ᵔ)',
+ '(*ノ∀`*)',
+ '(//▽//)',
+ '(//ω//)',
+ '(*^.^*)',
+ '(*ノ▽ノ)',
+ '( ̄▽ ̄*)ゞ',
+ '(*/▽\*)',
+ '(„ಡωಡ„)',
+ '( 〃▽〃)',
+ '(/▿\ )',
+ '(#><)',
+ '(; ̄Д ̄)',
+ '( ̄□ ̄」)',
+ '(# ̄0 ̄)',
+ '(# ̄ω ̄)',
+ '(¬_¬;)',
+ '(>m<)',
+ '(」°ロ°)」',
+ '(^^#)',
+ '(︶︹︺)',
+ '( ̄ヘ ̄)',
+ '( ̄︿ ̄)',
+ '(>﹏<)',
+ '(--_--)',
+ '凸( ̄ヘ ̄)',
+ '(⇀‸↼‶)',
+ 'o(>< )o',
+ '(」><)」',
+ '(ᗒᗣᗕ)՞',
+ '(눈_눈)',
+ '(#`Д´)',
+ '(`皿´#)',
+ '(・`ω´・)',
+ '(`ー´)',
+ '凸(`△´#)',
+ '( `ε´ )',
+ 'ヽ(‵﹏´)ノ',
+ '(╬`益´)',
+ 'Σ(▼□▼メ)',
+ '(°ㅂ°╬)',
+ '(ノ°益°)ノ',
+ '(‡▼益▼)',
+ '(╬ Ò﹏Ó)',
+ '(凸ಠ益ಠ)凸',
+ '٩(ఠ益ఠ)۶',
+ '(ノಥ益ಥ)ノ',
+ '(≖、≖╬)',
+ '(ノ_<。)',
+ '(-_-)',
+ '(´-ω-`)',
+ '(μ_μ)',
+ '(ノД`)',
+ '(-ω-、)',
+ 'o(TヘTo)',
+ '(。╯︵╰。)',
+ '(个_个)',
+ '(╯︵╰,)',
+ '( ╥ω╥ )',
+ '(╯_╰)',
+ '(╥_╥)',
+ '(/ˍ・、)',
+ '(ノ_<、)',
+ '(╥﹏╥)',
+ '(つω`。)',
+ '(ノω・、)',
+ '(T_T)',
+ '(>_<)',
+ 'o(〒﹏〒)o',
+ '(ಥ﹏ಥ)',
+ '(ಡ‸ಡ)',
+ '~(>_<~)',
+ '☆⌒(>。<)',
+ '(☆_@)',
+ '(×_×)',
+ '(x_x)',
+ '(×_×)⌒☆',
+ '(x_x)⌒☆',
+ '(×﹏×)',
+ '☆(#××)',
+ '(+_+)',
+ '٩(× ×)۶',
+ '(メ﹏メ)',
+ '(ノωヽ)',
+ '(/。\)',
+ '(ノ_ヽ)',
+ '(″ロ゛)',
+ '(・人・)',
+ '\(〇_o)/',
+ '(/ω\)',
+ '(/_\)',
+ '〜(><)〜',
+ '┐( ̄ヘ ̄)┌',
+ '╮( ̄_ ̄)╭',
+ 'ヽ(ˇヘˇ)ノ',
+ '┐( ̄~ ̄)┌',
+ '┐(︶▽︶)┌',
+ '╮( ̄~ ̄)╭',
+ '╮(︶︿︶)╭',
+ '┐( ̄∀ ̄)┌',
+ '╮(︶▽︶)╭',
+ '┐( ̄ヮ ̄)┌',
+ 'ᕕ( ᐛ )ᕗ',
+ '┐(シ)┌',
+ '( ̄ω ̄;)',
+ 'σ( ̄、 ̄〃)',
+ '( ̄~ ̄;)',
+ '(・_・ヾ',
+ '(〃 ̄ω ̄〃ゞ',
+ '(・_・;)',
+ '(@_@)',
+ '(・・;)ゞ',
+ 'Σ( ̄。 ̄ノ)',
+ '(・・ ) ?',
+ '(◎ ◎)ゞ',
+ '(ーー;)',
+ '(¯ ¯٥)',
+ '(¬_¬)',
+ '(→_→)',
+ '(¬ ¬)',
+ '(¬‿¬ )',
+ '(¬_¬ )',
+ '(←_←)',
+ '(¬ ¬ )',
+ '(¬‿¬ )',
+ '(↼_↼)',
+ '(⇀_⇀)',
+ '(ᓀ ᓀ)',
+ 'w(°o°)w',
+ 'ヽ(°〇°)ノ',
+ 'Σ(O_O)',
+ 'Σ(°ロ°)',
+ '(⊙_⊙)',
+ '(o_O)',
+ '(O_O;)',
+ '(O.O)',
+ '(°ロ°) !',
+ '(o_O) !',
+ '(□_□)',
+ 'Σ(□_□)',
+ '∑(O_O;)',
+ '(*・ω・)ノ',
+ '( ̄▽ ̄)ノ',
+ '(°▽°)/',
+ '(^-^*)/',
+ '\(⌒▽⌒)',
+ 'ヾ(☆▽☆)',
+ '(^0^)ノ',
+ '~ヾ(・ω・)',
+ '(・∀・)ノ',
+ 'ヾ(・ω・*)',
+ '(*°ー°)ノ',
+ '(・_・)ノ',
+ '( ̄ω ̄)/',
+ '(⌒ω⌒)ノ',
+ '(≧▽≦)/',
+ '(✧∀✧)/',
+ '( ̄▽ ̄)/',
+ '(つ≧▽≦)つ',
+ '(つ✧ω✧)つ',
+ '(っಠ‿ಠ)っ',
+ '(づ◡﹏◡)づ',
+ '⊂( ̄▽ ̄)⊃',
+ '(^_~)',
+ '( ゚o⌒)',
+ '(^_-)≡☆',
+ '(^ω~)',
+ '(>ω^)',
+ '(~人^)',
+ '(^_-)',
+ '( -_・)',
+ '(^_<)〜☆',
+ '(^人<)〜☆',
+ '☆⌒(ゝ。∂)',
+ '(^_<)',
+ '(^_−)☆',
+ '(・ω<)☆',
+ '(^.~)☆',
+ '(^.~)',
+ '(>ᴗ•)',
+ 'm(_ _)m',
+ '(シ_ _)シ',
+ 'm(. .)m',
+ '<(_ _)>',
+ '人(_ _*)',
+ '(*_ _)人',
+ '(シ. .)シ',
+ '(* ̄ii ̄)',
+ '( ̄ハ ̄*)',
+ '\\( ̄ハ ̄)',
+ '(^་།^)',
+ '(^〃^)',
+ '( ̄ ¨ヽ ̄)',
+ '( ̄ ; ̄)',
+ '( ̄ ;; ̄)',
+ '|・ω・)',
+ 'ヘ(・_|',
+ '|ω・)ノ',
+ 'ヾ(・|',
+ '|д・)',
+ '|_ ̄))',
+ '|▽//)',
+ '|_・)',
+ '|・д・)ノ',
+ '|ʘ‿ʘ)╯',
+ '__φ(..)',
+ '__φ(。。)',
+ '(=①ω①=)',
+ '(=`ω´=)',
+ '(=^‥^=)',
+ '( =ω= )',
+ '( =ω= )',
+ '(^◔ᴥ◔^)',
+ '(^◕ᴥ◕^)',
+ 'ต(=ω=)ต',
+ '( ̄(エ) ̄)',
+ '(/(エ)\)',
+ 'ʕ ᵔᴥᵔ ʔ',
+ 'ʕ •ᴥ• ʔ',
+ 'ʕಠᴥಠʔ',
+ '∪^ェ^∪',
+ '∪・ω・∪',
+ '∪ ̄- ̄∪',
+ '∪・ェ・∪',
+ 'U^皿^U',
+ 'UTェTU',
+ 'U^ェ^U',
+ 'V●ᴥ●V',
+ 'U・ᴥ・U',
+ '/(>×<)\',
+ '/(˃ᆺ˂)\',
+ '( ̄(ω) ̄)',
+ '( ̄Θ ̄)',
+ '(`・Θ・´)',
+ '(◉Θ◉)',
+ '(・θ・)',
+ '(・Θ・)',
+ '(・Θ・)',
+ 'ζ°)))彡',
+ '>°))))彡',
+ '(°))<<',
+ '―(T_T)→',
+ 'Q(`⌒´Q)',
+ '(っ˘ڡ˘ς)',
+ 'ヘ( ̄ω ̄ヘ)',
+ '(〜 ̄▽ ̄)〜',
+ '〜( ̄▽ ̄〜)',
+ '(ノ≧∀≦)ノ',
+ '√( ̄‥ ̄√)',
+ '└(^^)┐',
+ '┌(^^)┘',
+ '\( ̄▽ ̄)\',
+ '/( ̄▽ ̄)/',
+ '(^_^♪)',
+ '(~˘▽˘)~',
+ '~(˘▽˘~)',
+ '(〜 ̄△ ̄)〜',
+ '(~‾▽‾)~',
+ '~(˘▽˘)~',
+ '(≖ ͜ʖ≖)',
+ '( ̄^ ̄)ゞ',
+ '(-‸ლ)',
+ '(oT-T)尸',
+ '(ಠ_ಠ)',
+ '( ̄﹃ ̄)',
+ '( ・ω・)☞',
+ '(⌐■_■)',
+ '(◕‿◕✿)',
+]
diff --git a/src/components/modules/dashboard/crossbell/XLogEnabled.tsx b/src/components/modules/dashboard/crossbell/XLogEnabled.tsx
new file mode 100644
index 0000000000..8785fbd9d8
--- /dev/null
+++ b/src/components/modules/dashboard/crossbell/XLogEnabled.tsx
@@ -0,0 +1,8 @@
+import { lazy } from 'react'
+
+const XLogEnableImpl = lazy(() =>
+ import('./XlogSwitch').then((mo) => ({ default: mo.XlogSwitch })),
+)
+export const XLogEnable = () => {
+ return 'ethereum' in window ? : null
+}
diff --git a/src/components/modules/dashboard/crossbell/XlogSwitch.tsx b/src/components/modules/dashboard/crossbell/XlogSwitch.tsx
new file mode 100644
index 0000000000..e9ce2516a1
--- /dev/null
+++ b/src/components/modules/dashboard/crossbell/XlogSwitch.tsx
@@ -0,0 +1,65 @@
+import { useQuery } from '@tanstack/react-query'
+import { useEffect } from 'react'
+import { useAtom, useStore } from 'jotai'
+
+import { XLogIcon } from '~/components/icons/platform/XLogIcon'
+import { LabelSwitch } from '~/components/ui/switch'
+import { PublishEvent } from '~/events'
+import { apiClient } from '~/lib/request'
+
+import { syncToXlogAtom } from '../writing/atoms'
+import { CrossBellConnector } from './legacy'
+
+export const XlogSwitch = () => {
+ const [checked, setChecked] = useAtom(syncToXlogAtom)
+
+ const { data: siteId, isLoading } = useQuery({
+ queryKey: ['xlog', 'conf'],
+ queryFn: async () => {
+ const { data } =
+ await apiClient.proxy.options.thirdPartyServiceIntegration.get<{
+ data: { xLogSiteId: string }
+ }>()
+ const { xLogSiteId } = data
+
+ const CrossBellConnector = await import('./legacy').then(
+ (mo) => mo.CrossBellConnector,
+ )
+ CrossBellConnector.setSiteId(xLogSiteId)
+ return xLogSiteId
+ },
+ })
+
+ if (!siteId && !isLoading) {
+ setChecked(false)
+ }
+
+ return (
+
+
+ 同步到 XLog
+
+
+
+ )
+}
+
+const PublishEventSubscriber = () => {
+ const store = useStore()
+ useEffect(() => {
+ window.addEventListener(PublishEvent.type, (e: Event) => {
+ const ev = e as PublishEvent
+
+ const enabled = store.get(syncToXlogAtom)
+ if (!enabled) return
+
+ CrossBellConnector.createOrUpdate(ev.data)
+ })
+ }, [store])
+
+ return null
+}
diff --git a/src/components/modules/dashboard/crossbell/api.ts b/src/components/modules/dashboard/crossbell/api.ts
new file mode 100644
index 0000000000..65eb771e2c
--- /dev/null
+++ b/src/components/modules/dashboard/crossbell/api.ts
@@ -0,0 +1,77 @@
+import axios from 'axios'
+import type { NoteDto, PostDto } from '~/models/writing'
+import type { CrossBell } from './types'
+
+import { apiClient } from '~/lib/request'
+
+const endpoint = 'https://indexer.crossbell.io/v1'
+// characterId 52055
+// noteId 431
+export class CrossBellApi {
+ private http = axios.create({
+ baseURL: '/api/crossbell',
+ })
+ constructor(
+ // private readonly token = process.env.CROSSBELL_TOKEN,
+ private readonly characterId: number,
+ ) {
+ this.http.interceptors.request.use((config) => {
+ // config.headers.Authorization = `Bearer ${this.token}`
+ return config
+ })
+ }
+
+ getNote(noteId: number) {
+ return this.http.get(
+ `/notes/${this.characterId}/${noteId}`,
+ )
+ }
+
+ async createNote(model: NoteDto | PostDto) {
+ const articleUrl = await apiClient.proxy
+ .helper('url-builder')(model.id)
+ .get<{
+ data: string
+ }>()
+ .then(({ data }) => data)
+ .catch(() => '')
+
+ if (!articleUrl) {
+ throw new Error('文章链接生成失败')
+ }
+
+ const { title, text } = model
+ const contentWithFooter = `${text}
+
+此文由 [Shiro · Light dashboard](https://github.com/innei/Shiro) 同步更新至 xLog
+原始链接为 <${articleUrl}>
`
+ const slug = 'slug' in model ? model.slug : `note-${model.nid}`
+
+ return this.http.put(
+ `/siwe/contract/characters/${this.characterId}/notes`,
+ {
+ metadata: {
+ tags: ['post'],
+ type: 'note',
+ title,
+ isPost: true,
+ summary: '',
+ published: true,
+ applications: ['xlog'],
+ content: contentWithFooter,
+
+ sources: ['xlog'],
+ attributes: [
+ {
+ value: slug, // 这里是自定义 slug
+ trait_type: 'xlog_slug',
+ },
+ ],
+ attachments: [],
+ },
+ locked: false,
+ linkItemType: null,
+ },
+ )
+ }
+}
diff --git a/src/components/modules/dashboard/crossbell/index.ts b/src/components/modules/dashboard/crossbell/index.ts
new file mode 100644
index 0000000000..cc60903dc4
--- /dev/null
+++ b/src/components/modules/dashboard/crossbell/index.ts
@@ -0,0 +1,3 @@
+export * from './api'
+
+export * from './types'
diff --git a/src/components/modules/dashboard/crossbell/legacy.ts b/src/components/modules/dashboard/crossbell/legacy.ts
new file mode 100644
index 0000000000..7c967dcfa3
--- /dev/null
+++ b/src/components/modules/dashboard/crossbell/legacy.ts
@@ -0,0 +1,420 @@
+import { createContract, Indexer } from 'crossbell'
+import Unidata from 'unidata.js'
+import type { NoteDto, PostDto } from '~/models/writing'
+import type { Contract } from 'crossbell'
+
+import { apiClient } from '~/lib/request'
+import { toast } from '~/lib/toast'
+
+const unidata = new Unidata()
+
+const crossbellGQLEndpoint = 'https://indexer.crossbell.io/v1/graphql'
+
+export class CrossBellConnector {
+ static SITE_ID = ''
+ static setSiteId(siteId: string) {
+ this.SITE_ID = siteId
+ }
+
+ private static contract: Contract | null = null
+
+ private static async prepare() {
+ if (this.contract) {
+ return this.contract
+ }
+
+ const metamask = (window as any).ethereum
+ const contract = createContract(metamask)
+
+ await contract.walletClient.requestAddresses()
+
+ if (!contract.account.address) {
+ throw new Error('未连接到 metamask')
+ }
+ this.contract = contract
+ return contract
+ }
+
+ static createOrUpdate(data: NoteDto | PostDto) {
+ // eslint-disable-next-line no-async-promise-executor
+ return new Promise(async (resolve) => {
+ if (!('ethereum' in window)) {
+ resolve(null)
+ return
+ }
+ if (!this.SITE_ID) {
+ resolve(null)
+ return
+ }
+
+ const SITE_ID = this.SITE_ID
+
+ await this.prepare()
+
+ let postCallOnce = false
+ let pageId = data.meta?.xLog?.pageId
+ const slug = 'slug' in data ? data.slug : `note-${data.nid}`
+
+ const post = async () => {
+ if (postCallOnce) return
+ const { text, title } = data
+ postCallOnce = true
+
+ // FIXME 如果 xLog 不存在这个 pageId,会报错 metamask rpc error
+ // 如果是在 xLog 删除了这个文章,但是 mx 这边没有同步,会导致这个问题
+ // 这里还是验证一下吧,只针对 note 的场景,post 还是根据记录的 pageId 来,因为 post 的 slug 不是固定的但是 note 的 nid 是固定的。
+ // 如果 post 的 slug 改了,那么就在 xlog 拿不到 pageId 了,这个时候就会出问题(修改文章都是变成新增)
+
+ if (!pageId || this.isNoteModel(data))
+ pageId = await this.getCrossbellNotePageIdBySlug(slug)
+
+ const articleUrl = await apiClient.proxy
+ .helper('url-builder')(data.id)
+ .get<{
+ data: string
+ }>()
+ .then(({ data }) => data)
+ .catch(() => '')
+
+ if (!articleUrl) {
+ throw new Error('文章链接生成失败')
+ }
+
+ const contentWithFooter = `${text}
+
+此文由 [Mix Space](https://github.com/mx-space) 同步更新至 xLog
+原始链接为 <${articleUrl}>
`
+
+ toast.info('正在发布到 xLog...')
+
+ const input = {
+ siteId: SITE_ID,
+ content: contentWithFooter,
+ title,
+ isPost: true,
+ slug,
+ published: true,
+ applications: ['xlog'],
+ externalUrl: `https://${SITE_ID}.xlog.app/${slug}`,
+ pageId,
+ tags:
+ 'tags' in data
+ ? data.tags.toString()
+ : this.isNoteModel(data)
+ ? '生活笔记'
+ : '',
+ publishedAt: data.created,
+ }
+
+ return unidata.notes.set(
+ {
+ source: 'Crossbell Note',
+ identity: input.siteId,
+ platform: 'Crossbell',
+ action: input.pageId ? 'update' : 'add',
+ },
+ {
+ ...(input.externalUrl && { related_urls: [input.externalUrl] }),
+ ...(input.pageId && { id: input.pageId }),
+ ...(input.title && { title: input.title }),
+ ...(input.content && {
+ body: {
+ content: input.content,
+ mime_type: 'text/markdown',
+ },
+ }),
+ ...(input.publishedAt && {
+ date_published: input.publishedAt,
+ }),
+
+ tags: [
+ input.isPost ? 'post' : 'page',
+ ...(input.tags
+ ?.split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => tag) || []),
+ ],
+ applications: ['xlog'],
+ ...(input.slug && {
+ attributes: [
+ {
+ trait_type: 'xlog_slug',
+ value: input.slug,
+ },
+ ],
+ }),
+ },
+ )
+ }
+
+ await post().catch((err) => {
+ console.error(err)
+ toast.error('xLog 发布失败')
+
+ throw err
+ })
+
+ toast.success('xLog 发布成功')
+
+ let nextPageId = pageId
+ if (!nextPageId) {
+ nextPageId = await this.getCrossbellNotePageIdBySlug(slug)
+ }
+
+ if (!nextPageId) {
+ toast.error('无法获取 Crossbell Note pageId 任务终止')
+ return
+ }
+ // update meta for pageId
+ await this.updateModel(data, {
+ pageId: nextPageId,
+ })
+
+ const crossbellNoteData = await this.getCrossbellNoteData(
+ nextPageId.split('-')[1],
+ )
+ if (!crossbellNoteData) {
+ toast.error('无法获取 Crossbell Note 任务终止')
+ return
+ }
+ const {
+ metadata,
+ uri,
+ blockNumber,
+ owner,
+ transactionHash,
+ updatedTransactionHash,
+ } = crossbellNoteData
+
+ // "metadata": {
+ // "network": "Crossbell",
+ // "proof": "52055-184",
+ // "blockNumber": 31902501,
+ // "owner": "0x0cc14dd429303aee55bfb56529b81d2a300362ed",
+ // "transactions": [
+ // "0x2906e8b6a321a4f53ab07d58b3227398022e55c0c18b52201edfc1a68f942956",
+ // "0xbb572893c077f488172a52edc67cab0b485713d8a21312052a1a1cb4f74c8675"
+ // ]
+ // }
+ console.debug(crossbellNoteData)
+ await this.updateModel(data, {
+ pageId: nextPageId,
+ related_urls: [...metadata.content.external_urls],
+ metadata: {
+ network: 'Crossbell',
+ proof: nextPageId,
+ blockNumber,
+ owner,
+ transactions: [
+ transactionHash,
+ ...(updatedTransactionHash &&
+ updatedTransactionHash !== transactionHash
+ ? [updatedTransactionHash]
+ : []),
+ ],
+ },
+ cid: uri.split('ipfs://')[1],
+ })
+
+ resolve(null)
+ })
+ }
+
+ private static isNoteModel(data: NoteDto | PostDto): data is NoteDto {
+ return 'nid' in data
+ }
+
+ private static async updateModel(
+ data: NoteDto | PostDto,
+
+ meta: {
+ pageId?: string
+ cid?: string
+ related_urls?: string[]
+ metadata?: any
+ },
+ ) {
+ const id = data.id
+ const { cid, pageId, related_urls, metadata } = meta || {}
+
+ // delete undefined value in meta object
+
+ for (const key in meta) {
+ // @ts-expect-error
+ if (meta[key] === undefined) {
+ // @ts-expect-error
+ delete meta[key]
+ }
+ }
+
+ const patchedData = {
+ meta: {
+ ...data.meta,
+ xLog: {
+ ...data.meta?.xLog,
+ pageId,
+ cid,
+ related_urls,
+ metadata,
+ },
+ },
+ }
+ if (this.isNoteModel(data)) {
+ await apiClient.proxy.notes(id).patch({
+ data: patchedData,
+ })
+ } else {
+ await apiClient.proxy.posts(id).patch({
+ data: patchedData,
+ })
+ }
+ }
+
+ private static indexer = new Indexer()
+
+ private static async getCharacterId() {
+ const indexer = this.indexer
+ const result = await indexer.character.getByHandle(this.SITE_ID)
+
+ if (!result) {
+ return ''
+ }
+
+ return result.characterId
+ }
+ private static async getCrossbellNoteData(noteId: string) {
+ await this.prepare()
+ const characterId = await this.getCharacterId()
+ if (!characterId) return
+ return fetch(crossbellGQLEndpoint, {
+ body: JSON.stringify({
+ operationName: 'getNote',
+ query: `query getNote {
+ note(
+ where: {
+ note_characterId_noteId_unique: {
+ characterId: ${characterId},
+ noteId: ${noteId},
+ },
+ },
+ ) {
+ characterId
+ noteId
+ uri
+ metadata {
+ uri
+ content
+ }
+ owner
+ createdAt
+ updatedAt
+ publishedAt
+ transactionHash
+ blockNumber
+ updatedTransactionHash
+ updatedBlockNumber
+ }
+ }`,
+ variables: {},
+ }),
+ headers: {
+ 'content-type': 'application/json',
+ },
+ method: 'POST',
+ mode: 'cors',
+ credentials: 'omit',
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ return data.data?.note as {
+ blockNumber: number
+ characterId: string
+ createdAt: string
+ metadata: {
+ content: {
+ external_urls: string[]
+ type: string
+ }
+ uri: string
+ }
+ noteId: string
+ owner: string
+ publishedAt: string
+ transactionHash: string
+ updatedAt: string
+ updatedBlockNumber: number
+ updatedTransactionHash: string
+ uri: string
+ }
+ })
+ }
+
+ private static async getCrossbellNotePageIdBySlug(slug?: string) {
+ await this.prepare()
+ const characterId = await this.getCharacterId()
+ if (!characterId) return
+ return fetch(crossbellGQLEndpoint, {
+ body: JSON.stringify({
+ operationName: 'getNotes',
+ query: `query getNotes {
+ notes(
+ where: {
+ characterId: {
+ equals: ${characterId},
+ },
+ deleted: {
+ equals: false,
+ },
+ metadata: {
+ AND: [
+ {
+ content: {
+ path: ["sources"],
+ array_contains: ["xlog"]
+ },
+ },
+ {
+ OR: [
+ {
+ content: {
+ path: ["attributes"],
+ array_contains: [{
+ trait_type: "xlog_slug",
+ value: "${slug}",
+ }]
+ }
+ },
+ {
+ content: {
+ path: ["title"],
+ equals: "${decodeURIComponent(slug!)}"
+ },
+ }
+ ]
+ }
+ ]
+ },
+ },
+ orderBy: [{ createdAt: desc }],
+ take: 1,
+ ) {
+ characterId
+ noteId
+ }
+ }`,
+ }),
+ headers: {
+ 'content-type': 'application/json',
+ },
+ method: 'POST',
+ mode: 'cors',
+ credentials: 'omit',
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ const note = data.data.notes[0]
+ if (!note) return
+ return `${note.characterId}-${note.noteId}`
+ })
+ }
+}
diff --git a/src/components/modules/dashboard/crossbell/types.ts b/src/components/modules/dashboard/crossbell/types.ts
new file mode 100644
index 0000000000..c9ddad25dc
--- /dev/null
+++ b/src/components/modules/dashboard/crossbell/types.ts
@@ -0,0 +1,47 @@
+/* eslint-disable @typescript-eslint/no-namespace */
+export namespace CrossBell {
+ export interface CrossbellNote {
+ characterId: number
+ noteId: number
+ linkItemType: null
+ linkKey: string
+ deleted: boolean
+ locked: boolean
+ contractAddress: string
+ uri: string
+ operator: string
+ owner: string
+ createdAt: string
+ updatedAt: string
+ deletedAt: null
+ publishedAt: string
+ transactionHash: string
+ blockNumber: number
+ logIndex: number
+ updatedTransactionHash: string
+ updatedBlockNumber: number
+ updatedLogIndex: number
+ metadata: Metadata
+ toHeadCharacter: null
+ toHeadNote: null
+ }
+ export interface Metadata {
+ uri: string
+ type: string
+ content: Content
+ status: string
+ }
+ export interface Content {
+ tags: string[]
+ title: string
+ content: string
+ sources: string[]
+ attributes: AttributesItem[]
+ external_urls: string[]
+ date_published: string
+ }
+ export interface AttributesItem {
+ value: string
+ trait_type: string
+ }
+}
diff --git a/src/components/modules/dashboard/editor/Milkdown/MilkdownEditor.tsx b/src/components/modules/dashboard/editor/Milkdown/MilkdownEditor.tsx
new file mode 100644
index 0000000000..322bb7cdd4
--- /dev/null
+++ b/src/components/modules/dashboard/editor/Milkdown/MilkdownEditor.tsx
@@ -0,0 +1,133 @@
+import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'
+import {
+ forwardRef,
+ useCallback,
+ useId,
+ useImperativeHandle,
+ useRef,
+} from 'react'
+import type { Config } from '@milkdown/core'
+
+import {
+ defaultValueCtx,
+ Editor,
+ EditorStatus,
+ editorViewCtx,
+ editorViewOptionsCtx,
+ rootCtx,
+ serializerCtx,
+} from '@milkdown/core'
+import { clipboard } from '@milkdown/plugin-clipboard'
+import { history } from '@milkdown/plugin-history'
+import { indent } from '@milkdown/plugin-indent'
+import { listener, listenerCtx } from '@milkdown/plugin-listener'
+import { commonmark } from '@milkdown/preset-commonmark'
+import { replaceAll } from '@milkdown/utils'
+
+import { useIsUnMounted } from '~/hooks/common/use-is-unmounted'
+
+import styles from './index.module.css'
+
+export interface MilkdownProps {
+ initialMarkdown?: string
+ readonly?: boolean
+ onBlur?(): void
+ onChange?(e: { target: { value: string } }): void
+ onMarkdownChange?(markdown: string): void
+ onCreated?(): void
+}
+
+export interface MilkdownRef {
+ getMarkdown(): string | undefined
+ setMarkdown(markdown: string): void
+}
+
+export const MilkdownEditor = forwardRef(
+ (props, ref) => {
+ return (
+
+
+
+ )
+ },
+)
+
+MilkdownEditor.displayName = 'MilkdownEditor'
+
+const MilkdownEditorImpl = forwardRef(
+ (props, ref) => {
+ const { initialMarkdown } = props
+
+ const editorCtxRef = useRef[0]>()
+ const editorRef = useRef()
+ const getMarkdown = useCallback(
+ () =>
+ editorRef.current?.action((ctx) => {
+ const editorView = ctx.get(editorViewCtx)
+ const serializer = ctx.get(serializerCtx)
+ return serializer(editorView.state.doc)
+ }),
+ [],
+ )
+ const { get } = useEditor((root) => {
+ const editor = Editor.make()
+ editorRef.current = editor
+
+ return editor
+ .config((ctx) => {
+ editorCtxRef.current = ctx
+
+ ctx.set(rootCtx, root)
+ ctx.set(defaultValueCtx, initialMarkdown || '')
+ editorCtxRef.current.update(editorViewOptionsCtx, (ctx) => ({
+ ...ctx,
+ editable: () => !props.readonly,
+ }))
+
+ ctx
+ .get(listenerCtx)
+ .markdownUpdated((ctx, markdown) => {
+ if (isUnMounted.current) return
+
+ props.onMarkdownChange?.(markdown)
+ props.onChange?.({ target: { value: markdown } })
+ })
+ .blur(() => {
+ props.onBlur?.()
+ })
+ })
+ .use(commonmark)
+ .use(listener)
+ .use(clipboard)
+ .use(history)
+ .use(indent)
+ .onStatusChange((o) => {
+ if (o === EditorStatus.Created) {
+ props.onCreated?.()
+ }
+ })
+ }, [])
+
+ const setMarkdown = useCallback(
+ (markdown: string) => {
+ get()?.action(replaceAll(markdown))
+ },
+ [get],
+ )
+
+ useImperativeHandle(ref, () => ({
+ getMarkdown,
+ setMarkdown,
+ }))
+
+ const isUnMounted = useIsUnMounted()
+
+ const id = useId()
+ return (
+
+
+
+ )
+ },
+)
+MilkdownEditorImpl.displayName = 'MilkdownEditorImpl'
diff --git a/src/components/modules/dashboard/editor/Milkdown/index.module.css b/src/components/modules/dashboard/editor/Milkdown/index.module.css
new file mode 100644
index 0000000000..480e76518a
--- /dev/null
+++ b/src/components/modules/dashboard/editor/Milkdown/index.module.css
@@ -0,0 +1,139 @@
+.editor *[contenteditable='true']:focus-visible {
+ outline: 0 !important;
+}
+
+.editor {
+ @apply leading-loose;
+
+ caret-color: theme(colors.primary);
+}
+
+.editor,
+.editor :global(.milkdown),
+.editor :global([data-milkdown-root='true']),
+.editor :global([contenteditable='true']) {
+ height: 100%;
+}
+
+.editor h1 {
+ font-size: 1.2rem;
+ font-weight: 500;
+}
+
+.editor h2 {
+ font-size: 1.15rem;
+ font-weight: 500;
+}
+
+.editor h3 {
+ font-size: 1.1rem;
+ font-weight: 500;
+}
+
+.editor h4 {
+ font-size: 1.05rem;
+ font-weight: 500;
+}
+
+.editor a {
+ @apply text-accent underline;
+}
+
+.editor blockquote {
+ @apply border-l-4 border-accent pl-4 font-serif not-italic;
+}
+
+.editor code:not(pre > code) {
+ @apply inline-block rounded-sm bg-slate-200/50 p-1 font-mono font-normal text-accent;
+}
+
+.editor p {
+ @apply my-2;
+}
+
+[data-theme='dark'] .editor a {
+ @apply text-accent;
+}
+
+[data-theme='dark'] .editor blockquote {
+ @apply border-accent;
+}
+
+[data-theme='dark'] .editor code:not(pre > code) {
+ @apply bg-slate-800/50 text-accent;
+}
+
+.editor pre code {
+ @apply text-inherit;
+}
+
+.editor img {
+ @apply !my-0 inline-block max-w-full;
+}
+
+.editor .tableWrapper {
+ @apply relative mb-2 overflow-x-auto;
+}
+
+.editor table {
+ @apply !m-4 !overflow-visible text-sm shadow-md sm:rounded-lg;
+}
+
+.editor td,
+.editor th {
+ @apply !px-6 !py-3;
+}
+
+.editor tr {
+ @apply border-b border-gray-200 dark:border-gray-600;
+}
+
+[data-theme='dark'] .editor tr {
+ @apply border-gray-600;
+}
+
+.editor :where(td, th) p {
+ @apply !m-0;
+}
+
+.editor :where(td, th):nth-child(odd) {
+ @apply bg-gray-50;
+}
+
+[data-theme='dark'] .editor :where(td, th):nth-child(odd) {
+ @apply bg-gray-900;
+}
+
+.editor.ProseMirror .selectedCell:after {
+ @apply bg-accent/30;
+}
+
+/* A little workaround to turn the element into a space */
+.editor br[data-is-inline='true'],
+.editor br[data-is-inline='true']::after {
+ content: ' ';
+}
+
+.editor ul {
+ @apply list-inside list-disc;
+}
+
+.editor ol {
+ @apply list-inside list-decimal;
+}
+
+.editor li > p {
+ @apply inline;
+}
+
+.editor {
+ font-size: 14px;
+}
+
+.editor pre {
+ @apply rounded-md bg-slate-200 p-2;
+}
+
+[data-theme='dark'] .editor pre {
+ @apply bg-neutral-800;
+}
diff --git a/src/components/modules/dashboard/editor/Milkdown/index.ts b/src/components/modules/dashboard/editor/Milkdown/index.ts
new file mode 100644
index 0000000000..8844cc81ec
--- /dev/null
+++ b/src/components/modules/dashboard/editor/Milkdown/index.ts
@@ -0,0 +1 @@
+export * from './MilkdownEditor'
diff --git a/src/components/modules/dashboard/editor/index.ts b/src/components/modules/dashboard/editor/index.ts
new file mode 100644
index 0000000000..14d344b14a
--- /dev/null
+++ b/src/components/modules/dashboard/editor/index.ts
@@ -0,0 +1 @@
+export * from './Milkdown'
diff --git a/src/components/modules/dashboard/home/DataStat.tsx b/src/components/modules/dashboard/home/DataStat.tsx
new file mode 100644
index 0000000000..a90f6961ba
--- /dev/null
+++ b/src/components/modules/dashboard/home/DataStat.tsx
@@ -0,0 +1,300 @@
+import { useQuery } from '@tanstack/react-query'
+import { useMemo } from 'react'
+import { useRouter } from 'next/navigation'
+import type { ReactNode } from 'react'
+
+import { StyledButton } from '~/components/ui/button'
+import { RelativeTime } from '~/components/ui/relative-time'
+import { apiClient } from '~/lib/request'
+import { toast } from '~/lib/toast'
+
+import {
+ CodeIcon,
+ FluentGuest28Filled,
+ IcBaselineFavoriteBorder,
+ IcSharpPeopleOutline,
+ MingcuteGame1Line,
+ NotebookMinimalistic,
+ PhAlignLeft,
+ RedisIcon,
+ SolarPieChartBroken,
+ TablerActivityHeartbeat,
+} from './icons'
+
+interface CardProps {
+ label: string
+ value: number | string
+ icon: ReactNode
+ actions?: {
+ name: string
+ onClick: () => void
+ primary?: boolean
+ showBadage?: boolean
+ }[]
+}
+
+export const DataStat = () => {
+ const { data: stat, dataUpdatedAt } = useQuery({
+ queryKey: ['stat'],
+ queryFn: () => fetchStat(),
+
+ refetchInterval: 1000 * 15,
+ })
+
+ const { data: counts } = useQuery({
+ queryKey: ['readAndLikeCounts'],
+ queryFn: () => fetchReadAndLikeCounts(),
+ select(data) {
+ if (!data) return
+ return {
+ readAndLikeCounts: data,
+ }
+ },
+ })
+
+ const { data: siteWordCount } = useQuery({
+ queryKey: ['siteWordCount'],
+ queryFn: () => fetchSiteWordCount(),
+ select(data) {
+ if (!data) return
+ return data.data.length
+ },
+ })
+ const { readAndLikeCounts } = counts || {}
+ const router = useRouter()
+
+ const dataStat = useMemo(() => {
+ if (!stat) return []
+ return [
+ {
+ label: '博文',
+ value: stat.posts,
+ icon: ,
+ actions: [
+ {
+ name: '撰写',
+ primary: true,
+ onClick() {
+ router.push('/dashboard/posts/edit')
+ },
+ },
+ {
+ name: '管理',
+ onClick() {
+ router.push('/dashboard/posts/list')
+ },
+ },
+ ],
+ },
+
+ {
+ label: '手记',
+ value: stat.notes,
+ icon: ,
+ actions: [
+ {
+ name: '撰写',
+ primary: true,
+ onClick() {
+ router.push('/dashboard/notes/edit')
+ },
+ },
+ {
+ name: '管理',
+ onClick() {
+ router.push('/dashboard/notes/list')
+ },
+ },
+ ],
+ },
+
+ {
+ label: '页面',
+ value: stat.pages,
+ icon: ,
+ actions: [
+ {
+ primary: true,
+ name: '管理',
+ onClick() {
+ router.push('/dashboard/pages')
+ },
+ },
+ ],
+ },
+
+ {
+ label: '分类',
+ value: stat.categories,
+ icon: ,
+ actions: [
+ {
+ primary: true,
+ name: '管理',
+ onClick() {
+ router.push('/dashboard/posts/category')
+ },
+ },
+ ],
+ },
+
+ {
+ label: '未读评论',
+ value: stat.allComments,
+ icon: ,
+ actions: [
+ {
+ primary: true,
+ name: '管理',
+ onClick() {
+ router.push('/dashboard/comments')
+ },
+ },
+ ],
+ },
+
+ {
+ label: '缓存',
+ value: 'Redis',
+ icon: ,
+ actions: [
+ {
+ primary: false,
+ name: '清除 API 缓存',
+ onClick() {
+ apiClient.proxy.clean_catch.get().then(() => {
+ toast.success('清除成功')
+ })
+ },
+ },
+ {
+ primary: false,
+ name: '清除数据缓存',
+ onClick() {
+ apiClient.proxy.clean_redis.get().then(() => {
+ toast.success('清除成功')
+ })
+ },
+ },
+ ],
+ },
+
+ {
+ label: 'API 总调用次数',
+ value: stat.callTime,
+ icon: ,
+ },
+ {
+ label: '今日 IP 访问次数',
+ value: stat.todayIpAccessCount,
+ icon: ,
+ },
+ {
+ label: '全站字符数',
+ value: siteWordCount,
+ icon: ,
+ },
+
+ {
+ label: '总阅读量',
+ value: readAndLikeCounts?.totalReads,
+ icon: ,
+ },
+ {
+ label: '总点赞数',
+ value: readAndLikeCounts?.totalLikes,
+ icon: ,
+ },
+
+ {
+ label: '当前在线访客',
+ value: stat.online,
+ icon: ,
+ },
+ {
+ label: '今日访客',
+ value: stat.todayOnlineTotal,
+ icon: ,
+ },
+ {
+ value: stat.todayMaxOnline,
+ label: '今日最多同时在线人数',
+ icon: ,
+ },
+ ]
+ }, [
+ readAndLikeCounts?.totalLikes,
+ readAndLikeCounts?.totalReads,
+ router,
+ siteWordCount,
+ stat,
+ ])
+
+ return (
+
+
+ 数据看板:
+
+ 数据更新于:
+
+
+
+ {dataStat.map((stat) => {
+ return (
+
+
{stat.label}
+
+
+ {formatNumber(stat.value)}
+
+
+
+ {stat.icon}
+
+
+
+ {stat.actions?.map((action) => {
+ return (
+
+ {action.name}
+
+ )
+ })}
+
+
+ )
+ })}
+
+
+ )
+}
+
+const formatNumber = (num: string | number) => {
+ const isNumber = !Number.isNaN(+num)
+ if (!isNumber) return num
+ return Intl.NumberFormat().format(+num)
+}
+
+const fetchStat = async () => {
+ const counts = (await apiClient.aggregate.proxy.stat.get()) as any
+
+ return counts
+}
+
+const fetchReadAndLikeCounts = async () => {
+ return await apiClient.aggregate.proxy.count_read_and_like.get<{
+ totalLikes: number
+ totalReads: number
+ }>()
+}
+
+const fetchSiteWordCount = async () => {
+ return await apiClient.proxy.aggregate.count_site_words.get<{
+ data: { length: number }
+ }>()
+}
diff --git a/src/components/modules/dashboard/home/Hitokoto.tsx b/src/components/modules/dashboard/home/Hitokoto.tsx
new file mode 100644
index 0000000000..064c3d84e0
--- /dev/null
+++ b/src/components/modules/dashboard/home/Hitokoto.tsx
@@ -0,0 +1,98 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { MotionButtonBase } from '~/components/ui/button'
+import { toast } from '~/lib/toast'
+
+export const Hitokoto = () => {
+ const {
+ data: hitokoto,
+ refetch,
+ isLoading,
+ } = useQuery({
+ queryKey: ['hitokoto'],
+ queryFn: () =>
+ fetchHitokoto([
+ SentenceType.动画,
+ SentenceType.原创,
+ SentenceType.哲学,
+ SentenceType.文学,
+ ]),
+ refetchInterval: 1000 * 60 * 60 * 24,
+ staleTime: Infinity,
+ select(data) {
+ const postfix = Object.values({
+ from: data.from,
+ from_who: data.from_who,
+ creator: data.creator,
+ }).filter(Boolean)[0]
+ if (!data.hitokoto) {
+ return '没有获取到句子信息'
+ } else {
+ return data.hitokoto + (postfix ? ` —— ${postfix}` : '')
+ }
+ },
+ })
+
+ if (!hitokoto) return null
+ if (isLoading) return
+ return (
+
+
{hitokoto}
+
+ refetch()}>
+
+
+
+ {
+ navigator.clipboard.writeText(hitokoto)
+ toast.success('已复制')
+ toast.info(hitokoto)
+ }}
+ >
+
+
+
+
+ )
+}
+
+enum SentenceType {
+ '动画' = 'a',
+ '漫画' = 'b',
+ '游戏' = 'c',
+ '文学' = 'd',
+ '原创' = 'e',
+ '来自网络' = 'f',
+ '其他' = 'g',
+ '影视' = 'h',
+ '诗词' = 'i',
+ '网易云' = 'j',
+ '哲学' = 'k',
+ '抖机灵' = 'l',
+}
+const fetchHitokoto = async (
+ type: SentenceType[] | SentenceType = SentenceType.文学,
+) => {
+ const json = await fetch(
+ `https://v1.hitokoto.cn/${
+ Array.isArray(type)
+ ? `?${type.map((t) => `c=${t}`).join('&')}`
+ : `?c=${type}`
+ }`,
+ )
+ const data = (await (json.json() as unknown)) as {
+ id: number
+ hitokoto: string
+ type: string
+ from: string
+ from_who: string
+ creator: string
+ creator_uid: number
+ reviewer: number
+ uuid: string
+ created_at: string
+ }
+
+ return data
+}
diff --git a/src/components/modules/dashboard/home/Shiju.tsx b/src/components/modules/dashboard/home/Shiju.tsx
new file mode 100644
index 0000000000..36727691a5
--- /dev/null
+++ b/src/components/modules/dashboard/home/Shiju.tsx
@@ -0,0 +1,58 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { FloatPopover } from '~/components/ui/float-popover'
+
+export const ShiJu = () => {
+ const { data, isLoading } = useQuery({
+ queryKey: ['shiju'],
+ queryFn: () => getJinRiShiCiOne(),
+ staleTime: Infinity,
+
+ select(data) {
+ return {
+ shiju: data.content,
+ shijuData: data,
+ }
+ },
+ })
+ if (isLoading) return
+ const origin = data?.shijuData.origin
+
+ if (!origin) return null
+ return (
+ {data?.shiju}}>
+
+
+ {origin.title}
+
+
+ 【{origin.dynasty.replace(/代$/, '')}】{origin.author}
+
+
+ {origin.content.map((c) => (
+
+ {c}
+
+ ))}
+
+
+
+ )
+}
+
+interface ShiJuData {
+ id: number
+ content: string
+ origin: {
+ title: string
+ dynasty: string
+ author: string
+ content: string[]
+ matchTags: string[]
+ }
+}
+export const getJinRiShiCiOne = async () => {
+ const res = await fetch('https://v2.jinrishici.com/one.json')
+ const json = await res.json()
+ return json.data as ShiJuData
+}
diff --git a/src/components/modules/dashboard/home/Version.tsx b/src/components/modules/dashboard/home/Version.tsx
new file mode 100644
index 0000000000..0a761eb711
--- /dev/null
+++ b/src/components/modules/dashboard/home/Version.tsx
@@ -0,0 +1,38 @@
+import { useQuery } from '@tanstack/react-query'
+
+import PKG from '~/../package.json'
+import { apiClient } from '~/lib/request'
+
+export const Version = () => {
+ const { data: version, isLoading } = useQuery({
+ queryKey: ['version'],
+ queryFn: () => {
+ return apiClient.proxy.get()
+ },
+ refetchInterval: 1000 * 60 * 60 * 24,
+ })
+ if (isLoading)
+ return (
+
+ )
+
+ return (
+
+
+
+ Shiro 版本:{PKG.version}
+
+
+ Mix Space Core 版本:{version?.version || 'N/A'}
+
+
+
+ )
+}
+
+interface AppInfo {
+ name: string
+ version: string
+}
diff --git a/src/components/modules/dashboard/home/icons.tsx b/src/components/modules/dashboard/home/icons.tsx
new file mode 100644
index 0000000000..014617085e
--- /dev/null
+++ b/src/components/modules/dashboard/home/icons.tsx
@@ -0,0 +1,168 @@
+import type { SVGProps } from 'react'
+
+export const CodeIcon = () => (
+
+)
+export const RedisIcon = () => (
+
+)
+
+export function TablerActivityHeartbeat(props: SVGProps) {
+ return (
+
+ )
+}
+
+export function SolarPieChartBroken(props: SVGProps) {
+ return (
+
+ )
+}
+
+export const PhAlignLeft = () => (
+
+)
+
+export const NotebookMinimalistic = () => {
+ return (
+
+ )
+}
+
+export function IcBaselineFavoriteBorder(props: SVGProps) {
+ return (
+
+ )
+}
+
+export function MingcuteGame1Line(props: SVGProps) {
+ return (
+
+ )
+}
+
+export function FluentGuest28Filled(props: SVGProps) {
+ return (
+
+ )
+}
+
+export function IcSharpPeopleOutline(props: SVGProps) {
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/home/index.ts b/src/components/modules/dashboard/home/index.ts
new file mode 100644
index 0000000000..924e1e7b2e
--- /dev/null
+++ b/src/components/modules/dashboard/home/index.ts
@@ -0,0 +1 @@
+export * from './Hitokoto'
diff --git a/src/components/modules/dashboard/ip/IpInfoPopover.tsx b/src/components/modules/dashboard/ip/IpInfoPopover.tsx
new file mode 100644
index 0000000000..c125e3d185
--- /dev/null
+++ b/src/components/modules/dashboard/ip/IpInfoPopover.tsx
@@ -0,0 +1,79 @@
+import { useQuery } from '@tanstack/react-query'
+import { useLayoutEffect, useState } from 'react'
+
+import { FloatPopover } from '~/components/ui/float-popover'
+import { apiClient } from '~/lib/request'
+
+interface IpInfoPopoverProps {
+ ip: string
+}
+export const IpInfoPopover: Component = (props) => {
+ const [ipInfo, setIpInfo] = useState(null)
+
+ const setIpInfoText = (info: IP) => {
+ setIpInfo(`IP: ${info.ip}
+ 城市:${
+ [info.countryName, info.regionName, info.cityName]
+ .filter(Boolean)
+ .join(' - ') || 'N/A'
+ }
+ ISP: ${info.ispDomain || 'N/A'}
+ 组织:${info.ownerDomain || 'N/A'}
+ 范围:${info.range ? Object.values(info.range).join(' - ') : 'N/A'}
+ `)
+ }
+
+ const { ip, className } = props
+
+ const { data, isLoading, refetch } = useQuery({
+ queryKey: ['ip', ip],
+ queryFn: async () => {
+ const data: any = await apiClient.proxy.fn('built-in').ip.get({
+ params: {
+ ip,
+ },
+ })
+ return data
+ },
+ enabled: false,
+ retry: false,
+ })
+
+ useLayoutEffect(() => {
+ if (data) setIpInfoText(data as IP)
+ }, [data])
+
+ return (
+ {
+ refetch()
+ }}
+ TriggerComponent={() => {ip}}
+ >
+ {isLoading ? (
+ '...'
+ ) : (
+
+ )}
+
+ )
+}
+
+interface IP {
+ ip: string
+ countryName: string
+ regionName: string
+ cityName: string
+ ownerDomain: string
+ ispDomain: string
+ range?: {
+ from: string
+ to: string
+ }
+}
diff --git a/src/components/modules/dashboard/ip/index.ts b/src/components/modules/dashboard/ip/index.ts
new file mode 100644
index 0000000000..edbee36b25
--- /dev/null
+++ b/src/components/modules/dashboard/ip/index.ts
@@ -0,0 +1 @@
+export * from './IpInfoPopover'
diff --git a/src/components/modules/dashboard/layouts/index.tsx b/src/components/modules/dashboard/layouts/index.tsx
new file mode 100644
index 0000000000..43c544941f
--- /dev/null
+++ b/src/components/modules/dashboard/layouts/index.tsx
@@ -0,0 +1,37 @@
+import type { FC, PropsWithChildren } from 'react'
+
+import { RootPortal } from '~/components/ui/portal'
+import { clsxm } from '~/lib/helper'
+
+export const MainLayout: FC = (props) => {
+ return (
+
+
+ {props.children}
+
+
+ )
+}
+
+export const OffsetMainLayout: Component = (props) => {
+ return (
+
+ {props.children}
+
+ )
+}
+
+export const OffsetHeaderLayout: Component = (props) => {
+ return (
+
+
+ {props.children}
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/note-editing/NoteNid.tsx b/src/components/modules/dashboard/note-editing/NoteNid.tsx
new file mode 100644
index 0000000000..a5c378e933
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/NoteNid.tsx
@@ -0,0 +1,17 @@
+import { useAggregationSelector } from '~/providers/root/aggregation-data-provider'
+
+import { useNoteModelSingleFieldAtom } from './data-provider'
+
+export const NoteNid = () => {
+ const webUrl = location.origin
+
+ const [nid] = useNoteModelSingleFieldAtom('nid')
+
+ const latestNid = useAggregationSelector((s) => s.latestNoteId)
+
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/note-editing/constants.ts b/src/components/modules/dashboard/note-editing/constants.ts
new file mode 100644
index 0000000000..a2bc908352
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/constants.ts
@@ -0,0 +1,18 @@
+export const MOOD_SET = [
+ '开心',
+ '伤心',
+ '决心',
+ '坚定',
+ '痛恨',
+ '生气',
+ '悲哀',
+ '痛苦',
+ '可怕',
+ '不快',
+ '可恶',
+ '担心',
+ '绝望',
+ '焦虑',
+ '激动',
+] as const
+export const WEATHER_SET = ['晴', '多云', '雨', '阴', '雪', '雷雨'] as const
diff --git a/src/components/modules/dashboard/note-editing/data-provider.tsx b/src/components/modules/dashboard/note-editing/data-provider.tsx
new file mode 100644
index 0000000000..d43d1fd737
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/data-provider.tsx
@@ -0,0 +1,49 @@
+import { createModelDataProvider } from 'jojoo/react'
+import { useContext, useMemo } from 'react'
+import { produce } from 'immer'
+import { atom, useAtom } from 'jotai'
+import type { NoteDto } from '~/models/writing'
+
+export const {
+ useModelDataSelector: useNoteModelDataSelector,
+ useSetModelData: useNoteModelSetModelData,
+ useGetModelData: useNoteModelGetModelData,
+
+ ModelDataAtomProvider: NoteModelDataAtomProvider,
+
+ ModelDataAtomContext,
+} = createModelDataProvider()
+
+export const useNoteModelSingleFieldAtom = <
+ T extends keyof NoteDto = keyof NoteDto,
+>(
+ key: T,
+) => {
+ const ctxAtom = useContext(ModelDataAtomContext)
+ if (!ctxAtom)
+ throw new Error(
+ 'useNoteModelSingleFieldAtom must be used inside NoteModelDataAtomProvider',
+ )
+ return useAtom(
+ useMemo(() => {
+ return atom(
+ (get) => {
+ const data = get(ctxAtom)
+
+ return data?.[key]
+ },
+ (get, set, update: any) => {
+ set(ctxAtom, (prev) => {
+ return produce(prev, (draft) => {
+ ;(draft as any)[key as any] = update
+ })
+ })
+ },
+ )
+ }, [ctxAtom, key]),
+ ) as any as [
+ NonNullable,
+
+ (update: NoteDto[T]) => NoteDto[T] | void,
+ ]
+}
diff --git a/src/components/modules/dashboard/note-editing/index.ts b/src/components/modules/dashboard/note-editing/index.ts
new file mode 100644
index 0000000000..ce69418817
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/index.ts
@@ -0,0 +1,2 @@
+export * from './sidebar'
+export * from './data-provider'
diff --git a/src/components/modules/dashboard/note-editing/sidebar/DateInput.tsx b/src/components/modules/dashboard/note-editing/sidebar/DateInput.tsx
new file mode 100644
index 0000000000..8582e42bd7
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/sidebar/DateInput.tsx
@@ -0,0 +1,17 @@
+import { SidebarDateInputField } from '../../writing/SidebarDateInputField'
+import { useNoteModelSingleFieldAtom } from '../data-provider'
+
+export const CustomCreatedInput = () => {
+ return (
+
+ )
+}
+
+export const PublicAtInput = () => {
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/note-editing/sidebar/NoteCombinedSwitch.tsx b/src/components/modules/dashboard/note-editing/sidebar/NoteCombinedSwitch.tsx
new file mode 100644
index 0000000000..02990eecdd
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/sidebar/NoteCombinedSwitch.tsx
@@ -0,0 +1,69 @@
+import { useState } from 'react'
+
+import { AdvancedInput } from '~/components/ui/input'
+import { LabelSwitch } from '~/components/ui/switch'
+
+import { useNoteModelSingleFieldAtom } from '../data-provider'
+
+export const NoteCombinedSwitch = () => {
+ const [isHide, setIsHide] = useNoteModelSingleFieldAtom('hide')
+
+ const [allowComment, setAllowComment] =
+ useNoteModelSingleFieldAtom('allowComment')
+
+ const [hasMemory, setHasMemory] = useNoteModelSingleFieldAtom('hasMemory')
+ const [password, setPassword] = useNoteModelSingleFieldAtom('password')
+
+ const [passwordEnable, setPasswordEnable] = useState(!!password)
+
+ return (
+ <>
+
+ 隐藏
+
+
+ {
+ setPasswordEnable(checked)
+ if (!checked) setPassword('')
+ }}
+ >
+ 设定密码?
+
+ {passwordEnable && (
+ setPassword(e.target.value)}
+ />
+ )}
+
+
+ 允许评论
+
+
+
+ 标记为回忆项
+
+ >
+ )
+}
diff --git a/src/components/modules/dashboard/note-editing/sidebar/NoteWeatherAndMood.tsx b/src/components/modules/dashboard/note-editing/sidebar/NoteWeatherAndMood.tsx
new file mode 100644
index 0000000000..d44ba68bc4
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/sidebar/NoteWeatherAndMood.tsx
@@ -0,0 +1,59 @@
+import { Autocomplete } from '~/components/ui/auto-completion'
+
+import { SidebarSection } from '../../writing/SidebarBase'
+import { MOOD_SET, WEATHER_SET } from '../constants'
+import { useNoteModelSingleFieldAtom } from '../data-provider'
+
+export const NoteWeatherAndMood = () => {
+ const [weather, setWeather] = useNoteModelSingleFieldAtom('weather')
+ const [mood, setMood] = useNoteModelSingleFieldAtom('mood')
+
+ return (
+ <>
+
+ ({ name: w, value: w }))}
+ onSuggestionSelected={(suggestion) => {
+ setWeather(suggestion.value)
+ }}
+ placeholder=" "
+ onChange={(e) => {
+ setWeather(e.target.value)
+ }}
+ onConfirm={(value) => {
+ setWeather(value)
+ }}
+ />
+
+
+
+ ({ name: w, value: w }))}
+ onSuggestionSelected={(suggestion) => {
+ setMood(suggestion.value)
+ }}
+ onChange={(e) => {
+ setMood(e.target.value)
+ }}
+ onConfirm={(value) => {
+ setMood(value)
+ }}
+ />
+
+ >
+ )
+}
diff --git a/src/components/modules/dashboard/note-editing/sidebar/TopicSelector.tsx b/src/components/modules/dashboard/note-editing/sidebar/TopicSelector.tsx
new file mode 100644
index 0000000000..d526c09471
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/sidebar/TopicSelector.tsx
@@ -0,0 +1,45 @@
+import { useQuery } from '@tanstack/react-query'
+import React from 'react'
+import { produce } from 'immer'
+import type { SelectValue } from '~/components/ui/select'
+
+import { Select } from '~/components/ui/select'
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { adminQueries } from '~/queries/definition'
+
+import { SidebarSection } from '../../writing/SidebarBase'
+import {
+ useNoteModelDataSelector,
+ useNoteModelSetModelData,
+} from '../data-provider'
+
+export const TopicSelector = () => {
+ const { data, isLoading } = useQuery(adminQueries.note.allTopic())
+ const categoryId = useNoteModelDataSelector((data) => data?.topicId)
+ const setter = useNoteModelSetModelData()
+ const handleSelectionChange = useEventCallback((newCategoryId: string) => {
+ if (newCategoryId === categoryId) return
+
+ setter((prev) => {
+ return produce(prev, (draft) => {
+ draft.topicId = newCategoryId
+ })
+ })
+ })
+
+ const selectValues: SelectValue[] = (data || []).map((item) => ({
+ label: item.name,
+ value: item.id,
+ }))
+
+ return (
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/note-editing/sidebar/index.tsx b/src/components/modules/dashboard/note-editing/sidebar/index.tsx
new file mode 100644
index 0000000000..ee2ad3d6c6
--- /dev/null
+++ b/src/components/modules/dashboard/note-editing/sidebar/index.tsx
@@ -0,0 +1,55 @@
+import { XLogEnable } from '../../crossbell/XLogEnabled'
+import { CoverInput } from '../../writing/CoverInput'
+import { ImageDetailSection } from '../../writing/ImageDetailSection'
+import { MetaKeyValueEditSection } from '../../writing/MetaKeyValueEditSection'
+import { PresentComponentFab } from '../../writing/PresentComponentFab'
+import { SidebarWrapper } from '../../writing/SidebarBase'
+import { useNoteModelSingleFieldAtom } from '../data-provider'
+import { NoteCombinedSwitch } from './NoteCombinedSwitch'
+import { NoteWeatherAndMood } from './NoteWeatherAndMood'
+import { TopicSelector } from './TopicSelector'
+
+const Sidebar = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const NoteCoverInput = () => (
+
+)
+const ImageSection = () => {
+ const [images, setImages] = useNoteModelSingleFieldAtom('images')
+ const text = useNoteModelSingleFieldAtom('text')[0]
+ return (
+
+ )
+}
+
+const MetaSection = () => {
+ const [meta, setMeta] = useNoteModelSingleFieldAtom('meta')
+ return
+}
+
+export const NoteEditorSidebar = () => {
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/SlugInput.tsx b/src/components/modules/dashboard/post-editing/SlugInput.tsx
new file mode 100644
index 0000000000..29b79c3916
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/SlugInput.tsx
@@ -0,0 +1,50 @@
+import { useQuery } from '@tanstack/react-query'
+import { useEffect, useRef } from 'react'
+
+import { adminQueries } from '~/queries/definition'
+
+import { useBaseWritingAtom } from '../writing/BaseWritingProvider'
+
+export const SlugInput = () => {
+ const webUrl = location.origin
+
+ const [categoryId, setCategoryId] = useBaseWritingAtom('categoryId')
+
+ const [slug, setSlug] = useBaseWritingAtom('slug')
+ const { data: categories } = useQuery(adminQueries.post.allCategories())
+ const category = categories?.data?.[0]
+
+ const triggerOnceRef = useRef(false)
+ useEffect(() => {
+ if (triggerOnceRef.current) return
+ if (!categoryId && category) {
+ triggerOnceRef.current = true
+ setCategoryId(category.id)
+ }
+ }, [category, categoryId, setCategoryId])
+
+ const isLoading = !category
+ return (
+ <>
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {
+ setSlug(e.target.value)
+ }}
+ />
+
+ {slug}
+
+
+
+ >
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/data-provider.tsx b/src/components/modules/dashboard/post-editing/data-provider.tsx
new file mode 100644
index 0000000000..b8c4fe55f9
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/data-provider.tsx
@@ -0,0 +1,44 @@
+import { createModelDataProvider } from 'jojoo/react'
+import { useContext, useMemo } from 'react'
+import { produce } from 'immer'
+import { atom, useAtom } from 'jotai'
+import type { PostDto } from '~/models/writing'
+
+export const {
+ useModelDataSelector: usePostModelDataSelector,
+ useSetModelData: usePostModelSetModelData,
+ useGetModelData: usePostModelGetModelData,
+
+ ModelDataAtomProvider: PostModelDataAtomProvider,
+
+ ModelDataAtomContext,
+} = createModelDataProvider()
+
+export const usePostModelSingleFieldAtom = <
+ T extends keyof PostDto = keyof PostDto,
+>(
+ key: T,
+) => {
+ const ctxAtom = useContext(ModelDataAtomContext)
+ return useAtom(
+ useMemo(() => {
+ return atom(
+ (get) => {
+ const data = get(ctxAtom)
+ return data?.[key]
+ },
+ (get, set, update: any) => {
+ set(ctxAtom, (prev) => {
+ return produce(prev, (draft) => {
+ ;(draft as any)[key as any] = update
+ })
+ })
+ },
+ )
+ }, [ctxAtom, key]),
+ ) as any as [
+ NonNullable,
+
+ (update: PostDto[T]) => PostDto[T] | void,
+ ]
+}
diff --git a/src/components/modules/dashboard/post-editing/index.ts b/src/components/modules/dashboard/post-editing/index.ts
new file mode 100644
index 0000000000..c1e2421d1d
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/index.ts
@@ -0,0 +1,3 @@
+export * from './sidebar'
+export * from './data-provider'
+export * from './SlugInput'
diff --git a/src/components/modules/dashboard/post-editing/sidebar/CategorySelector.tsx b/src/components/modules/dashboard/post-editing/sidebar/CategorySelector.tsx
new file mode 100644
index 0000000000..b376250377
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/CategorySelector.tsx
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query'
+import React from 'react'
+import { produce } from 'immer'
+import type { SelectValue } from '~/components/ui/select'
+
+import { Select } from '~/components/ui/select'
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { adminQueries } from '~/queries/definition'
+
+import { SidebarSection } from '../../writing/SidebarBase'
+import {
+ usePostModelDataSelector,
+ usePostModelSetModelData,
+} from '../data-provider'
+
+export const CategorySelector = () => {
+ const { data, isLoading } = useQuery(adminQueries.post.allCategories())
+ const categoryId = usePostModelDataSelector((data) => data?.categoryId)
+ const setter = usePostModelSetModelData()
+ const handleSelectionChange = useEventCallback((newCategoryId: string) => {
+ if (newCategoryId === categoryId) return
+
+ setter((prev) => {
+ return produce(prev, (draft) => {
+ draft.categoryId = newCategoryId
+ })
+ })
+ })
+
+ const selectValues: SelectValue[] = (data?.data || []).map(
+ (item) => ({
+ label: item.name,
+ value: item.id,
+ }),
+ )
+
+ return (
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/sidebar/PostCombinedSwitch.tsx b/src/components/modules/dashboard/post-editing/sidebar/PostCombinedSwitch.tsx
new file mode 100644
index 0000000000..af0ec8e447
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/PostCombinedSwitch.tsx
@@ -0,0 +1,34 @@
+import { LabelSwitch } from '~/components/ui/switch'
+
+import { usePostModelSingleFieldAtom } from '../data-provider'
+
+export const PostCombinedSwitch = () => {
+ const [copyright, setCopyright] = usePostModelSingleFieldAtom('copyright')
+ const [pin, setPin] = usePostModelSingleFieldAtom('pin')
+
+ const [allowComment, setAllowComment] =
+ usePostModelSingleFieldAtom('allowComment')
+
+ return (
+ <>
+
+
+ {
+ setPin(pin ? new Date().toISOString() : null)
+ }}
+ >
+ 置顶
+
+
+
+ 允许评论
+
+ >
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/sidebar/RelatedPostSelector.tsx b/src/components/modules/dashboard/post-editing/sidebar/RelatedPostSelector.tsx
new file mode 100644
index 0000000000..1b8df48419
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/RelatedPostSelector.tsx
@@ -0,0 +1,207 @@
+import { useInfiniteQuery } from '@tanstack/react-query'
+import { useEffect, useMemo, useRef } from 'react'
+import { produce } from 'immer'
+import type { PostRelated } from '~/models/writing'
+import type { FC } from 'react'
+
+import { MotionButtonBase, StyledButton } from '~/components/ui/button'
+import { InjectContext, useModalStack } from '~/components/ui/modal'
+import { EllipsisHorizontalTextWithTooltip } from '~/components/ui/typography'
+import { routeBuilder, Routes } from '~/lib/route-builder'
+import { adminQueries } from '~/queries/definition'
+
+import { SidebarSection } from '../../writing/SidebarBase'
+import {
+ ModelDataAtomContext,
+ usePostModelDataSelector,
+ usePostModelSetModelData,
+} from '../data-provider'
+
+export const RelatedPostSelector = () => {
+ const related = usePostModelDataSelector((state) => state?.related)
+ const setter = usePostModelSetModelData()
+
+ const { present } = useModalStack({
+ wrapper: InjectContext(ModelDataAtomContext),
+ })
+
+ return (
+ {
+ present({
+ title: '选择关联阅读',
+ content: () => ,
+ clickOutsideToDismiss: false,
+ })
+ }}
+ >
+ 新增
+
+ }
+ >
+
+ {related.map((item, index) => {
+ const href = routeBuilder(Routes.Post, {
+ category: item.category.slug,
+ slug: item.slug,
+ })
+ return (
+ {
+ window.open(href, '_blank')
+ }}
+ key={index}
+ className="flex items-center justify-between rounded-md p-2 duration-200 hover:bg-gray-200 dark:bg-neutral-900 dark:hover:bg-zinc-800"
+ >
+
+ {item.title}
+
+ {
+ e.stopPropagation()
+ setter(
+ produce((draft) => {
+ if (!draft.related) return
+ draft.related.splice(index, 1)
+
+ if (!draft.relatedId) return
+ const idx = draft.relatedId.indexOf(item.id!)
+ if (idx === -1) return
+ draft.relatedId.splice(idx, 1)
+ }),
+ )
+ }}
+ >
+
+ 删除
+
+
+ )
+ })}
+
+
+ )
+}
+
+const RealtedPostList: FC = () => {
+ const relatedIds = usePostModelDataSelector((state) => state?.relatedId)
+
+ const currentId = usePostModelDataSelector((state) => state?.id)
+ const selection = useMemo(() => {
+ return new Set(relatedIds)
+ }, [relatedIds])
+
+ const setter = usePostModelSetModelData()
+
+ // @ts-expect-error
+ const { data, fetchNextPage, isLoading, isFetching } = useInfiniteQuery({
+ ...adminQueries.post.getRelatedList(),
+ getNextPageParam: (lastPage) =>
+ lastPage.pagination.hasNextPage
+ ? lastPage.pagination.currentPage + 1
+ : undefined,
+ getPreviousPageParam: (firstPage) => firstPage.pagination.currentPage - 1,
+ initialPageParam: 1 as number,
+ })
+
+ const scrollerRef = useRef(null)
+
+ useEffect(() => {
+ const $scroller = scrollerRef.current
+ if (!$scroller) return
+ $scroller.onscrollend = () => {
+ fetchNextPage()
+ }
+
+ return () => {
+ $scroller.onscrollend = null
+ }
+ }, [fetchNextPage])
+
+ const postMap = useMemo(() => {
+ const map = new Map()
+
+ if (!data) return map
+
+ data.pages.forEach((page) => {
+ page.data.forEach((post) => {
+ map.set(post.id, post)
+ })
+ })
+
+ return map
+ }, [data])
+
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/sidebar/SummaryInput.tsx b/src/components/modules/dashboard/post-editing/sidebar/SummaryInput.tsx
new file mode 100644
index 0000000000..32c7638608
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/SummaryInput.tsx
@@ -0,0 +1,21 @@
+import { TextArea } from '~/components/ui/input'
+
+import { SidebarSection } from '../../writing/SidebarBase'
+import { usePostModelSingleFieldAtom } from '../data-provider'
+
+export const SummaryInput = () => {
+ const [summary, setSummary] = usePostModelSingleFieldAtom('summary')
+
+ return (
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/sidebar/Tag.tsx b/src/components/modules/dashboard/post-editing/sidebar/Tag.tsx
new file mode 100644
index 0000000000..fec82c676c
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/Tag.tsx
@@ -0,0 +1,121 @@
+import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
+import { atom, useAtom, useSetAtom } from 'jotai'
+import type { Suggestion } from '~/components/ui/auto-completion'
+import type { FC } from 'react'
+
+import { CloseIcon } from '~/components/icons/close'
+import { Autocomplete } from '~/components/ui/auto-completion'
+import { MotionButtonBase } from '~/components/ui/button'
+import { clsxm } from '~/lib/helper'
+
+export interface TagProps {
+ canClose?: boolean
+ onClose?: () => void
+}
+
+export const PostTag: Component = ({
+ className,
+ children,
+ canClose,
+ onClose,
+}) => {
+ return (
+
+ {children}
+ {canClose && (
+
+
+
+ )}
+
+ )
+}
+const createTagEditingContextValue = () => ({
+ isEditing: atom(false),
+})
+const TagEditingContext = createContext<
+ ReturnType
+>(null!)
+
+export const AddTag: Component = ({ ...props }) => {
+ const ctxValue = useMemo(createTagEditingContextValue, [])
+ const [isEditing, setIsEditing] = useAtom(ctxValue.isEditing)
+ return (
+
+ {
+ setIsEditing(true)
+ }}
+ >
+
+
+ {isEditing && }
+
+ )
+}
+
+type TagBase = {
+ label: string
+ value: string
+}
+
+interface TagCompletionProp {
+ onSelected?: (suggestion: Suggestion) => void
+ onEnter?: (value: string) => void
+ existsTags?: TagBase[]
+ allTags?: TagBase[]
+}
+
+const TagCompletion: FC = (props) => {
+ const { allTags, existsTags, onEnter, onSelected } = props
+ const { isEditing } = useContext(TagEditingContext)
+ const setIsEditing = useSetAtom(isEditing)
+ const filteredSuggestions = useMemo(() => {
+ if (!allTags || !existsTags) return []
+
+ const tagIdSet = new Set(existsTags.map((tag) => tag.value))
+ return allTags
+ .filter((tag) => !tagIdSet.has(tag.value))
+ .map((tag) => ({
+ name: tag.label,
+ value: tag.value,
+ }))
+ }, [allTags, existsTags])
+
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ return (
+ {
+ onSelected?.(suggestion)
+ setIsEditing(false)
+ }}
+ suggestions={filteredSuggestions}
+ onConfirm={async (value) => {
+ onEnter?.(value)
+ setIsEditing(false)
+ }}
+ />
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/sidebar/TagsInput.tsx b/src/components/modules/dashboard/post-editing/sidebar/TagsInput.tsx
new file mode 100644
index 0000000000..cd100b340a
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/TagsInput.tsx
@@ -0,0 +1,73 @@
+import { useQuery } from '@tanstack/react-query'
+import React from 'react'
+
+import { adminQueries } from '~/queries/definition'
+
+import { SidebarSection } from '../../writing/SidebarBase'
+import {
+ usePostModelDataSelector,
+ usePostModelSetModelData,
+} from '../data-provider'
+import { AddTag, PostTag } from './Tag'
+
+export const TagsInput = () => {
+ const tags = usePostModelDataSelector((data) => data?.tags)
+
+ const setter = usePostModelSetModelData()
+ const handleClose = (tag: string) => {
+ setter((prev) => {
+ const newTags = prev.tags.filter((t) => t !== tag)
+ return {
+ ...prev,
+ tags: newTags,
+ }
+ })
+ }
+
+ return (
+
+
+ {tags?.map((tag) => (
+
handleClose(tag)}>
+ {tag}
+
+ ))}
+
+
+
+
+ )
+}
+
+const TagCompletion = () => {
+ const { data } = useQuery(adminQueries.post.getAllTags())
+
+ const setter = usePostModelSetModelData()
+
+ const existsTags = usePostModelDataSelector(
+ (data) => data?.tags.map((t) => ({ label: t, value: t })) ?? [],
+ )
+
+ return (
+ {
+ setter((prev) => {
+ return {
+ ...prev,
+ tags: [...prev.tags, suggestion.name],
+ }
+ })
+ }}
+ onEnter={async (value) => {
+ setter((prev) => {
+ return {
+ ...prev,
+ tags: [...prev.tags, value],
+ }
+ })
+ }}
+ />
+ )
+}
diff --git a/src/components/modules/dashboard/post-editing/sidebar/index.tsx b/src/components/modules/dashboard/post-editing/sidebar/index.tsx
new file mode 100644
index 0000000000..ae339faf3e
--- /dev/null
+++ b/src/components/modules/dashboard/post-editing/sidebar/index.tsx
@@ -0,0 +1,52 @@
+import { ImageDetailSection } from '../../writing/ImageDetailSection'
+import { MetaKeyValueEditSection } from '../../writing/MetaKeyValueEditSection'
+import { PresentComponentFab } from '../../writing/PresentComponentFab'
+import { SidebarWrapper } from '../../writing/SidebarBase'
+import { usePostModelSingleFieldAtom } from '../data-provider'
+import { CategorySelector } from './CategorySelector'
+import { PostCombinedSwitch } from './PostCombinedSwitch'
+import { RelatedPostSelector } from './RelatedPostSelector'
+import { SummaryInput } from './SummaryInput'
+import { TagsInput } from './TagsInput'
+
+const Sidebar = () => {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const PostImageSection = () => {
+ const [images, setImages] = usePostModelSingleFieldAtom('images')
+ const text = usePostModelSingleFieldAtom('text')[0]
+ return (
+
+ )
+}
+
+const PostMetaSection = () => {
+ const [meta, setMeta] = usePostModelSingleFieldAtom('meta')
+ return
+}
+
+export const PostEditorSidebar = () => {
+ return (
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/BaseWritingProvider.tsx b/src/components/modules/dashboard/writing/BaseWritingProvider.tsx
new file mode 100644
index 0000000000..f58b6cddcf
--- /dev/null
+++ b/src/components/modules/dashboard/writing/BaseWritingProvider.tsx
@@ -0,0 +1,49 @@
+import { createContext, useContext, useMemo } from 'react'
+import { produce } from 'immer'
+import { atom, useAtom } from 'jotai'
+import type { PrimitiveAtom } from 'jotai'
+import type { PropsWithChildren } from 'react'
+
+const BaseWritingContext = createContext>(null!)
+
+type BaseModelType = {
+ title: string
+ slug?: string
+ text: string
+ categoryId?: string
+ subtitle?: string
+}
+
+export const BaseWritingProvider = (
+ props: { atom: PrimitiveAtom } & PropsWithChildren,
+) => {
+ return (
+
+ {props.children}
+
+ )
+}
+
+export const useBaseWritingContext = () => {
+ return useContext(BaseWritingContext)
+}
+
+export const useBaseWritingAtom = (key: keyof BaseModelType) => {
+ const ctxAtom = useBaseWritingContext()
+ return useAtom(
+ useMemo(
+ () =>
+ atom(
+ (get) => get(ctxAtom)[key],
+ (get, set, newValue) => {
+ set(ctxAtom, (prev) => {
+ return produce(prev, (draft) => {
+ ;(draft as any)[key] = newValue
+ })
+ })
+ },
+ ),
+ [ctxAtom, key],
+ ),
+ )
+}
diff --git a/src/components/modules/dashboard/writing/CardMasonry.tsx b/src/components/modules/dashboard/writing/CardMasonry.tsx
new file mode 100644
index 0000000000..0f62df310c
--- /dev/null
+++ b/src/components/modules/dashboard/writing/CardMasonry.tsx
@@ -0,0 +1,90 @@
+'use client'
+
+import Masonry, { ResponsiveMasonry } from 'react-responsive-masonry'
+import { clsx } from 'clsx'
+import type { ReactNode } from 'react'
+
+import { useMaskScrollArea } from '~/hooks/shared/use-mask-scrollarea'
+import { clsxm } from '~/lib/helper'
+
+const columnsCountBreakPoints = {
+ 0: 1,
+ 600: 2,
+ 1024: 3,
+ 1280: 3,
+}
+
+export interface CardProps {
+ title: ReactNode
+ description: ReactNode
+
+ data?: T
+ slots?: Partial<{
+ right: (data?: T) => ReactNode
+ middle: (data?: T) => ReactNode
+ footer: (data?: T) => ReactNode
+ }>
+
+ children?: ReactNode
+
+ className?: string
+}
+export interface CardMasonryProps {
+ data: T[]
+
+ children: (data: T) => ReactNode
+}
+export const CardMasonry = (props: CardMasonryProps) => {
+ return (
+
+
+
+ {props.data.map((data) => props.children(data))}
+
+
+
+ )
+}
+
+export function Card(props: CardProps) {
+ const [scrollContainerRef, scrollClassname] =
+ useMaskScrollArea()
+
+ const { slots, className, title, description, children, data } = props
+
+ return (
+
+
+
+ {title}
+
+ {slots?.middle && (
+
+ {slots.middle?.(data)}
+
+ )}
+
+ {description}
+
+
+ {slots?.footer && (
+
+ {slots.footer?.(data)}
+
+ )}
+
+ {slots?.right?.(data)}
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/CoverInput.tsx b/src/components/modules/dashboard/writing/CoverInput.tsx
new file mode 100644
index 0000000000..f6f204e1fe
--- /dev/null
+++ b/src/components/modules/dashboard/writing/CoverInput.tsx
@@ -0,0 +1,61 @@
+import clsx from 'clsx'
+import type { FC } from 'react'
+
+import { CloseIcon } from '~/components/icons/close'
+import { Input } from '~/components/ui/input'
+import { toast } from '~/lib/toast'
+
+import { SidebarSection } from './SidebarBase'
+
+const isUrl = (url: string) => {
+ try {
+ return new URL(url).protocol.startsWith('http')
+ } catch (e) {
+ return false
+ }
+}
+export const CoverInput: FC<{
+ accessor: [any, (value: any) => void]
+}> = ({ accessor }) => {
+ const [meta, setMeta] = accessor
+
+ const value = meta?.cover || ''
+ const reset = () => {
+ const nextValue = {
+ ...meta,
+ }
+ delete nextValue.cover
+ setMeta(nextValue)
+ }
+ return (
+
+
+ {
+ const value = e.target.value
+
+ if (value === '') {
+ reset()
+ return
+ }
+ if (!isUrl(value)) {
+ toast.error('只能粘贴一个图片链接')
+ return
+ }
+ setMeta({
+ ...meta,
+ cover: value,
+ })
+ }}
+ />
+ {!!value && (
+
+ )}
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/EditorLayer.tsx b/src/components/modules/dashboard/writing/EditorLayer.tsx
new file mode 100644
index 0000000000..c5e1130a29
--- /dev/null
+++ b/src/components/modules/dashboard/writing/EditorLayer.tsx
@@ -0,0 +1,48 @@
+import type { FC, ReactNode } from 'react'
+
+import { useIsMobile } from '~/atoms'
+import { RootPortal } from '~/components/ui/portal'
+import { clsxm } from '~/lib/helper'
+
+export const EditorLayer: FC<{
+ children: ReactNode[]
+ mainClassName?: string
+}> = (props) => {
+ const { children, mainClassName } = props
+ const [TitleEl, HeaderEl, ContentEl, FooterEl, ...rest] = children
+
+ const isMobile = useIsMobile()
+ return (
+ <>
+
+
+
+ {isMobile ? (
+
+
+ {HeaderEl}
+
+
+ ) : (
+
+ {HeaderEl}
+
+ )}
+
+
+
+ {ContentEl}
+
+ {FooterEl}
+
+ {rest}
+ >
+ )
+}
diff --git a/src/components/modules/dashboard/writing/ImageDetailSection.tsx b/src/components/modules/dashboard/writing/ImageDetailSection.tsx
new file mode 100644
index 0000000000..63004d57d7
--- /dev/null
+++ b/src/components/modules/dashboard/writing/ImageDetailSection.tsx
@@ -0,0 +1,356 @@
+import { memo, useEffect, useMemo, useState } from 'react'
+import { marked } from 'marked'
+import type { WriteBaseType } from '~/models/writing'
+import type { FC } from 'react'
+
+import { MotionButtonBase, StyledButton } from '~/components/ui/button'
+import { Collapse } from '~/components/ui/collapse'
+import { Divider } from '~/components/ui/divider'
+import { AdvancedInput, AdvancedInputProvider } from '~/components/ui/input'
+import { Label, LabelProvider } from '~/components/ui/label'
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { uniqBy } from '~/lib/_'
+import { getDominantColor } from '~/lib/image'
+import { toast } from '~/lib/toast'
+
+const pickImagesFromMarkdown = (text: string) => {
+ const ast = marked.lexer(text)
+ const images = new Set()
+ function pickImage(node: any) {
+ if (node.type === 'image') {
+ images.add(node.href)
+ return
+ }
+ if (node.tokens && Array.isArray(node.tokens)) {
+ return node.tokens.forEach(pickImage)
+ }
+ }
+ ast.forEach(pickImage)
+
+ return [...images.values()]
+}
+
+type ArticleImage = WriteBaseType['images'][number]
+
+export interface ImageDetailSectionProps {
+ images: ArticleImage[]
+ onChange: (images: ArticleImage[]) => void
+ text: string
+ extraImages?: string[]
+
+ withDivider?: 'top' | 'bottom' | 'both'
+}
+
+export const ImageDetailSection: FC = (props) => {
+ const { images, text, onChange, extraImages, withDivider } = props
+
+ const originImageMap = useMemo(() => {
+ const map = new Map()
+ images.forEach((image) => {
+ map.set(image.src, image)
+ })
+ return map
+ }, [images])
+
+ const fromText = useMemo(() => {
+ return pickImagesFromMarkdown(text)
+ }, [text])
+
+ const hasTopDivider = withDivider === 'top' || withDivider === 'both'
+ const hasBottomDivider = withDivider === 'bottom' || withDivider === 'both'
+
+ const nextImages = useMemo(() => {
+ const basedImages: ArticleImage[] = text
+ ? uniqBy(
+ fromText
+ .map((src) => {
+ const existImageInfo = originImageMap.get(src)
+
+ return {
+ src,
+ height: existImageInfo?.height,
+ width: existImageInfo?.width,
+ type: existImageInfo?.type,
+ accent: existImageInfo?.accent,
+ } as any
+ })
+ .concat(images)
+ .filter(Boolean),
+ (s) => s.src,
+ )
+ : images
+ const srcSet = new Set()
+
+ for (const image of basedImages) {
+ image.src && srcSet.add(image.src)
+ }
+ const nextImages = basedImages.concat()
+ if (extraImages) {
+ // 需要过滤存在的图片
+ extraImages.forEach((src) => {
+ if (!srcSet.has(src)) {
+ nextImages.push({
+ src,
+ height: 0,
+ width: 0,
+ type: '',
+ accent: '',
+ })
+ }
+ })
+ }
+
+ return nextImages
+ }, [extraImages, images, originImageMap, JSON.stringify(fromText)])
+
+ const [loading, setLoading] = useState(false)
+ const handleCorrectImageDimensions = useEventCallback(async () => {
+ if (loading) return
+ setLoading(true)
+
+ const fetchImageTasks = await Promise.allSettled(
+ images.map((item) => {
+ return new Promise((resolve, reject) => {
+ const $image = new Image()
+ $image.src = item.src
+ $image.crossOrigin = 'Anonymous'
+ $image.onload = () => {
+ resolve({
+ width: $image.naturalWidth,
+ height: $image.naturalHeight,
+ src: item.src,
+ type: $image.src.split('.').pop() || '',
+ accent: getDominantColor($image),
+ })
+ }
+ $image.onerror = (err) => {
+ reject({
+ err,
+ src: item.src,
+ })
+ }
+ })
+ }),
+ )
+
+ setLoading(false)
+
+ const nextImageDimensions = [] as ArticleImage[]
+ fetchImageTasks.map((task) => {
+ if (task.status === 'fulfilled') nextImageDimensions.push(task.value)
+ else {
+ toast.error(`获取图片信息失败:${task.reason.src}: ${task.reason.err}`)
+ }
+ })
+
+ onChange(nextImageDimensions)
+
+ setLoading(false)
+ })
+
+ const handleOnChange = useEventCallback(
+ (
+ src: string,
+ key: T,
+ value: ArticleImage[T],
+ ) => {
+ if (key == 'src' && value === '') {
+ onChange(
+ nextImages.filter((item) => {
+ return item.src !== src
+ }),
+ )
+ return
+ }
+ onChange(
+ nextImages.map((item) => {
+ if (item.src === src) {
+ return {
+ ...item,
+ [key]: value,
+ }
+ }
+ return item
+ }),
+ )
+ },
+ )
+
+ if (!nextImages.length) return null
+
+ return (
+ <>
+ {hasTopDivider && }
+
+
+
图片信息
+
+ {loading && }
+ 刷新图片信息
+
+
+
+ {nextImages.map((image) => {
+ return (
+
+
+
+ )
+ })}
+
+
+ {hasBottomDivider && }
+ >
+ )
+}
+
+const Item: FC<
+ ArticleImage & {
+ handleOnChange: (
+ src: string,
+ key: T,
+ value: ArticleImage[T],
+ ) => void
+ }
+> = memo(({ handleOnChange, ...image }) => {
+ return (
+
+
+
+ {
+ const validValue = parseInt(e.target.value)
+ if (Number.isNaN(validValue)) return
+ handleOnChange(image.src, 'height', validValue)
+ }}
+ />
+ {
+ const validValue = parseInt(e.target.value)
+ if (Number.isNaN(validValue)) return
+ handleOnChange(image.src, 'width', validValue)
+ }}
+ />
+ {
+ handleOnChange(image.src, 'type', e.target.value)
+ }}
+ />
+
+
+
+
+ {
+ handleOnChange(image.src, 'accent', hex)
+ }}
+ />
+
+
+
+
+
+
+ {
+ window.open(image.src)
+ }}
+ >
+ 查看
+
+ {
+ handleOnChange(image.src, 'src', '')
+ }}
+ >
+ 重置
+
+
+
+
+
+ )
+})
+
+Item.displayName = 'AccordionItem'
+
+const ColorPicker: FC<{
+ accent: string
+ onChange: (hex: string) => void
+}> = ({ accent, onChange }) => {
+ const [currentColor, setCurrentColor] = useState(accent)
+ useEffect(() => {
+ setCurrentColor(accent)
+ }, [accent])
+
+ return (
+
+ )
+ // return (
+ //
+ // }
+ // >
+ //
+ // {/* {
+ // setCurrentColor(hex)
+ // })}
+ // onDestroy={useEventCallback(() => {
+ // onChange(currentColor)
+ // })}
+ // /> */}
+ //
+ // )
+}
+
+// const ColorPickerContent: FC<{
+// accent: string
+// onDestroy: () => void
+// onChange: (hex: string) => void
+// }> = ({ accent, onChange, onDestroy }) => {
+// useEffect(() => {
+// return () => {
+// onDestroy()
+// }
+// }, [onDestroy])
+
+// return (
+// {
+// onChange(hex)
+// }}
+// />
+// )
+// }
diff --git a/src/components/modules/dashboard/writing/ImportMarkdownButton.tsx b/src/components/modules/dashboard/writing/ImportMarkdownButton.tsx
new file mode 100644
index 0000000000..4b7541de4a
--- /dev/null
+++ b/src/components/modules/dashboard/writing/ImportMarkdownButton.tsx
@@ -0,0 +1,90 @@
+import { useState } from 'react'
+import { load } from 'js-yaml'
+import type { FC } from 'react'
+
+import { StyledButton } from '~/components/ui/button'
+import { TextArea } from '~/components/ui/input'
+import { DeclarativeModal } from '~/components/ui/modal/stacked/declarative-modal'
+import { useDisclosure } from '~/hooks/common/use-disclosure'
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { usePreventComposition } from '~/hooks/common/use-prevent-composition'
+import { useUncontrolledInput } from '~/hooks/common/use-uncontrolled-input'
+
+type ParsedValue = {
+ title?: string
+ text: string
+ meta?: Record
+}
+export const ImportMarkdownButton: FC<{
+ onParsedValue: (parsedValue: ParsedValue) => void
+}> = ({ onParsedValue }) => {
+ const [, getValue, ref] = useUncontrolledInput()
+ const { isOpen, onOpen, onOpenChange, onClose } = useDisclosure()
+ const handleParseContent = useEventCallback(() => {
+ let value = getValue()
+ if (!value) return
+
+ const hasHeaderYaml = /^---\n((.|\n)*?)\n---/.exec(value)
+
+ const parsedValue: ParsedValue = {} as any
+
+ if (hasHeaderYaml?.length) {
+ const headerYaml = hasHeaderYaml[1]
+ const meta: Record = load(headerYaml) as any
+
+ parsedValue.meta = meta
+
+ // remove header yaml
+ value = value.replace(hasHeaderYaml[0], '')
+ }
+ // trim value again
+ const str = value.trim()
+ const lines = str.split('\n')
+ // if first line is not empty, start with `#`
+ const title = lines[0].startsWith('#')
+ ? lines[0].replace(/^#/, '').trim()
+ : ''
+
+ if (title) {
+ parsedValue.title = title
+ lines.shift()
+ }
+
+ parsedValue.text = lines.join('\n').trim()
+
+ onParsedValue(parsedValue)
+
+ onClose()
+ })
+
+ const [textareaEl, setTextAreaEl] = useState()
+ usePreventComposition(textareaEl!)
+ return (
+ <>
+
+ 导入
+
+
+
+ >
+ )
+}
diff --git a/src/components/modules/dashboard/writing/ListSortAndFilter.tsx b/src/components/modules/dashboard/writing/ListSortAndFilter.tsx
new file mode 100644
index 0000000000..36941a9cef
--- /dev/null
+++ b/src/components/modules/dashboard/writing/ListSortAndFilter.tsx
@@ -0,0 +1,77 @@
+import { createContext, useMemo } from 'react'
+import { atom } from 'jotai'
+import type { FC, PropsWithChildren } from 'react'
+
+const filterAtom = atom([] as string[])
+const sortingAtom = atom({ key: 'created', order: 'desc' } as {
+ key: string
+ order: 'asc' | 'desc'
+})
+
+const ListSortAndFilterContext = createContext({
+ filterAtom,
+ sortingAtom,
+})
+
+export const defaultSortingKeyMap = {
+ created: '创建时间',
+ modified: '修改时间',
+} as Record
+
+type SortingOrderListItem = {
+ key: string
+ label: string
+}
+
+export const defaultSortingOrderList = [
+ {
+ key: 'desc',
+ label: '降序',
+ },
+ {
+ key: 'asc',
+ label: '升序',
+ },
+] as SortingOrderListItem[]
+
+const ListSortAndFilterListContext = createContext({
+ sortingKeyMap: defaultSortingKeyMap,
+ sortingOrderList: defaultSortingOrderList,
+})
+
+export const ListSortAndFilterProvider: FC<
+ PropsWithChildren<{
+ sortingKeyMap?: Record
+ sortingOrderList?: SortingOrderListItem[]
+
+ filterAtom: typeof filterAtom
+ sortingAtom: typeof sortingAtom
+ }>
+> = (props) => {
+ const { sortingOrderList, sortingKeyMap, children, sortingAtom, filterAtom } =
+ props
+
+ return (
+ ({
+ filterAtom,
+ sortingAtom,
+ }),
+ [filterAtom, sortingAtom],
+ )}
+ >
+ ({
+ sortingKeyMap: sortingKeyMap ?? defaultSortingKeyMap,
+ sortingOrderList: sortingOrderList ?? defaultSortingOrderList,
+ }),
+ [sortingKeyMap, sortingOrderList],
+ )}
+ >
+ {children}
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/MetaKeyValueEditSection.tsx b/src/components/modules/dashboard/writing/MetaKeyValueEditSection.tsx
new file mode 100644
index 0000000000..2ba26dfb0e
--- /dev/null
+++ b/src/components/modules/dashboard/writing/MetaKeyValueEditSection.tsx
@@ -0,0 +1,108 @@
+import { Label } from '@radix-ui/react-label'
+import { useMemo, useRef } from 'react'
+import type { FC } from 'react'
+
+import { StyledButton } from '~/components/ui/button'
+import { HighLighter } from '~/components/ui/code-highlighter'
+import { TextArea } from '~/components/ui/input'
+import { useModalStack } from '~/components/ui/modal'
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { toast } from '~/lib/toast'
+
+type KeyValueString = string
+interface MetaKeyValueEditSectionProps {
+ keyValue: object | KeyValueString
+ onChange: (keyValue: object) => void
+}
+
+const safeParse = (value: string) => {
+ try {
+ return JSON.parse(value)
+ } catch (e) {
+ return {}
+ }
+}
+
+const TAB_SIZE = 2
+
+export const MetaKeyValueEditSection: FC = (
+ props,
+) => {
+ const { keyValue, onChange } = props
+ const objectValue = useMemo(
+ () => (typeof keyValue === 'string' ? safeParse(keyValue) : keyValue),
+ [keyValue],
+ )
+ const { present } = useModalStack()
+ const handlePresentModal = useEventCallback(() => {
+ present({
+ title: `编辑元信息`,
+ clickOutsideToDismiss: false,
+ content: ({ dismiss }) => (
+
+ ),
+ })
+ })
+
+ const jsonString = JSON.stringify(objectValue, null, TAB_SIZE)
+ return (
+
+
+
+
+
+ 编辑
+
+
+
+
+ )
+}
+
+const isValidJSONString = (value: string) => {
+ try {
+ JSON.parse(value)
+ return true
+ } catch (e) {
+ return false
+ }
+}
+
+const EditorModal: FC<{
+ value: string
+ dismiss: () => void
+ onChange: (value: object) => void
+}> = ({ value, onChange, dismiss }) => {
+ const currentEditValueRef = useRef(value)
+
+ const handleSave = () => {
+ if (!isValidJSONString(currentEditValueRef.current)) {
+ toast.error('JSON 格式错误,请检查后再试')
+ return
+ }
+ onChange(JSON.parse(currentEditValueRef.current) as Record)
+
+ dismiss()
+ }
+
+ return (
+
+
+
+
+ 保存
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/PresentComponentFab.tsx b/src/components/modules/dashboard/writing/PresentComponentFab.tsx
new file mode 100644
index 0000000000..62e33aceb9
--- /dev/null
+++ b/src/components/modules/dashboard/writing/PresentComponentFab.tsx
@@ -0,0 +1,17 @@
+import type { FC } from 'react'
+
+import { FABPortable } from '~/components/ui/fab'
+import { PresentSheet } from '~/components/ui/sheet'
+import { Noop } from '~/lib/noop'
+
+export const PresentComponentFab: FC<{
+ Component: FC
+}> = ({ Component }) => {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/PreviewButton.tsx b/src/components/modules/dashboard/writing/PreviewButton.tsx
new file mode 100644
index 0000000000..29c2f69c6d
--- /dev/null
+++ b/src/components/modules/dashboard/writing/PreviewButton.tsx
@@ -0,0 +1,43 @@
+import { useEffect } from 'react'
+
+import { StyledButton } from '~/components/ui/button'
+import { EmitKeyMap } from '~/constants/keys'
+import { debounce } from '~/lib/_'
+
+export const PreviewButton = (props: {
+ getData: () => T
+}) => {
+ const storageKey = `preview-${props.getData().id}`
+ const handlePreview = async () => {
+ const url = new URL('/preview', location.origin)
+ url.searchParams.set('same-site', '1')
+ url.searchParams.set('storageKey', storageKey)
+
+ const finalUrl = url.toString()
+ localStorage.setItem(storageKey, JSON.stringify(props.getData()))
+ window.open(finalUrl)
+ }
+
+ useEffect(() => {
+ const handler = debounce(() => {
+ localStorage.setItem(storageKey, JSON.stringify(props.getData()))
+ }, 100)
+ window.addEventListener(EmitKeyMap.EditDataUpdate, handler)
+ handler()
+
+ return () => {
+ window.removeEventListener(EmitKeyMap.EditDataUpdate, handler)
+ localStorage.removeItem(storageKey)
+ }
+ })
+
+ return (
+
+ 预览
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/SidebarBase.tsx b/src/components/modules/dashboard/writing/SidebarBase.tsx
new file mode 100644
index 0000000000..4e9b9b0874
--- /dev/null
+++ b/src/components/modules/dashboard/writing/SidebarBase.tsx
@@ -0,0 +1,46 @@
+import { useId } from 'react'
+import { clsx } from 'clsx'
+import type { FC, PropsWithChildren } from 'react'
+
+import { Label } from '~/components/ui/label'
+import { useMaskScrollArea } from '~/hooks/shared/use-mask-scrollarea'
+
+export const SidebarWrapper = (props: PropsWithChildren) => {
+ const [ref, className] = useMaskScrollArea()
+ return (
+
+ {props.children}
+
+ )
+}
+
+export const SidebarSection: FC<
+ PropsWithChildren<{
+ label: string
+ className?: string
+
+ actions?: React.ReactNode[] | React.ReactNode
+ }>
+> = ({ label, children, className, actions }) => {
+ const id = useId()
+ return (
+
+
+
+ {!!actions &&
{actions}
}
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/SidebarDateInputField.tsx b/src/components/modules/dashboard/writing/SidebarDateInputField.tsx
new file mode 100644
index 0000000000..06b0742f0b
--- /dev/null
+++ b/src/components/modules/dashboard/writing/SidebarDateInputField.tsx
@@ -0,0 +1,44 @@
+import { useEffect, useState } from 'react'
+import dayjs from 'dayjs'
+import type { FC } from 'react'
+
+import { Input } from '~/components/ui/input'
+import { isValidDate } from '~/lib/datetime'
+
+export const SidebarDateInputField: FC<{
+ label?: string
+ getSet: [string | undefined, (value: any) => void]
+}> = ({ label, getSet }) => {
+ const [created, setCreated] = getSet
+
+ const [editingCreated, setEditingCreated] = useState(created)
+
+ const [reset, setReset] = useState(0)
+ useEffect(() => {
+ if (!created) return
+ setEditingCreated(dayjs(created).format('YYYY-MM-DD HH:mm:ss'))
+ }, [created, reset])
+
+ const [hasError, setHasError] = useState(false)
+ useEffect(() => {
+ if (!editingCreated) return
+ if (isValidDate(new Date(editingCreated))) {
+ setHasError(false)
+ } else setHasError(true)
+ }, [editingCreated, setCreated])
+
+ return (
+ {
+ if (!hasError) {
+ setCreated(editingCreated)
+ }
+ }}
+ onChange={(e) => {
+ setEditingCreated(e.target.value)
+ }}
+ />
+ )
+}
diff --git a/src/components/modules/dashboard/writing/TitleExtra.tsx b/src/components/modules/dashboard/writing/TitleExtra.tsx
new file mode 100644
index 0000000000..4381f9be12
--- /dev/null
+++ b/src/components/modules/dashboard/writing/TitleExtra.tsx
@@ -0,0 +1,49 @@
+import { PhEyeSlash } from '~/components/icons/EyeSlashIcon'
+import { MotionButtonBase } from '~/components/ui/button'
+import { EllipsisHorizontalTextWithTooltip } from '~/components/ui/typography'
+import { clsxm } from '~/lib/helper'
+import { apiClient } from '~/lib/request'
+
+type RequiredField = { id: string; title: string }
+type OptionalField = Partial<{
+ hide: boolean
+ pin: string | null
+}>
+
+export const TitleExtra = (props: {
+ data: T
+ className?: string
+}) => {
+ const { className, data } = props
+ const { title, id, hide, pin } = data
+
+ return (
+
+
+ {pin &&
}
+
+
+ {title}
+
+
+ {hide &&
}
+
{
+ const url = await apiClient.proxy
+ .helper('url-builder')(id)
+ .get<{
+ data: string
+ }>()
+
+ window.open(url?.data, '_blank')
+ }}
+ >
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/modules/dashboard/writing/TitleInput.tsx b/src/components/modules/dashboard/writing/TitleInput.tsx
new file mode 100644
index 0000000000..393510eea7
--- /dev/null
+++ b/src/components/modules/dashboard/writing/TitleInput.tsx
@@ -0,0 +1,23 @@
+import type { FC } from 'react'
+
+import { AdvancedInput } from '~/components/ui/input'
+
+import { useBaseWritingAtom } from './BaseWritingProvider'
+
+export const TitleInput: FC<{
+ label?: string
+}> = ({ label }) => {
+ const [title, setTitle] = useBaseWritingAtom('title')
+
+ return (
+ setTitle(e.target.value)}
+ />
+ )
+}
diff --git a/src/components/modules/dashboard/writing/Writing.tsx b/src/components/modules/dashboard/writing/Writing.tsx
new file mode 100644
index 0000000000..6d4bdb496f
--- /dev/null
+++ b/src/components/modules/dashboard/writing/Writing.tsx
@@ -0,0 +1,77 @@
+import React, { useEffect, useRef } from 'react'
+import { produce } from 'immer'
+import { atom, useAtomValue, useSetAtom, useStore } from 'jotai'
+import type { FC } from 'react'
+import type { MilkdownRef } from '../editor'
+
+import { useEventCallback } from '~/hooks/common/use-event-callback'
+import { clsxm } from '~/lib/helper'
+import { jotaiStore } from '~/lib/store'
+
+import { MilkdownEditor } from '../editor'
+import { useBaseWritingContext } from './BaseWritingProvider'
+import { TitleInput } from './TitleInput'
+
+export const Writing: FC<{
+ middleSlot?: React.ReactNode | React.FunctionComponent
+
+ titleLabel?: string
+}> = ({ middleSlot, titleLabel }) => {
+ const middleSlotElement =
+ typeof middleSlot === 'function'
+ ? React.createElement(middleSlot)
+ : middleSlot
+ return (
+ <>
+
+
+ {middleSlotElement && (
+
+ {middleSlotElement}
+
+ )}
+
+
+ >
+ )
+}
+
+const Editor = () => {
+ const ctxAtom = useBaseWritingContext()
+ const setAtom = useSetAtom(ctxAtom)
+ const setText = useEventCallback((text: string) => {
+ setAtom((prev) => {
+ return produce(prev, (draft) => {
+ draft.text = text
+ })
+ })
+ })
+ const store = useStore()
+ const handleMarkdownChange = useEventCallback(setText)
+ const milkdownRef = useRef(null)
+
+ useEffect(() => {
+ jotaiStore.set(milkdownRefAtom, milkdownRef.current)
+ return () => {
+ jotaiStore.set(milkdownRefAtom, null)
+ }
+ }, [])
+
+ return (
+
+
+
+ )
+}
+
+const milkdownRefAtom = atom(null)
+export const useEditorRef = () => useAtomValue(milkdownRefAtom)
diff --git a/src/components/modules/dashboard/writing/atoms/index.ts b/src/components/modules/dashboard/writing/atoms/index.ts
new file mode 100644
index 0000000000..9d41e708bf
--- /dev/null
+++ b/src/components/modules/dashboard/writing/atoms/index.ts
@@ -0,0 +1,5 @@
+import { atomWithStorage } from 'jotai/utils'
+
+import { buildNSKey } from '~/lib/ns'
+
+export const syncToXlogAtom = atomWithStorage(buildNSKey('sync-to-xlog'), true)
diff --git a/src/components/widgets/home/SocialIcon.tsx b/src/components/modules/home/SocialIcon.tsx
similarity index 100%
rename from src/components/widgets/home/SocialIcon.tsx
rename to src/components/modules/home/SocialIcon.tsx
diff --git a/src/components/widgets/note/NoteActionAside.tsx b/src/components/modules/note/NoteActionAside.tsx
similarity index 98%
rename from src/components/widgets/note/NoteActionAside.tsx
rename to src/components/modules/note/NoteActionAside.tsx
index 0aff306ceb..0fe50da9f5 100644
--- a/src/components/widgets/note/NoteActionAside.tsx
+++ b/src/components/modules/note/NoteActionAside.tsx
@@ -4,6 +4,7 @@ import { m, useAnimationControls, useForceUpdate } from 'framer-motion'
import { useIsMobile } from '~/atoms'
import { MotionButtonBase } from '~/components/ui/button'
+import { useModalStack } from '~/components/ui/modal'
import { NumberSmoothTransition } from '~/components/ui/number-transition/NumberSmoothTransition'
import { useIsClient } from '~/hooks/common/use-is-client'
import { isLikedBefore, setLikeId } from '~/lib/cookie'
@@ -18,7 +19,6 @@ import {
useCurrentNoteDataSelector,
} from '~/providers/note/CurrentNoteDataProvider'
import { useCurrentNoteNid } from '~/providers/note/CurrentNoteIdProvider'
-import { useModalStack } from '~/providers/root/modal-stack-provider'
import { useIsEoFWrappedElement } from '~/providers/shared/WrappedElementProvider'
import {
diff --git a/src/components/widgets/note/NoteBanner.tsx b/src/components/modules/note/NoteBanner.tsx
similarity index 100%
rename from src/components/widgets/note/NoteBanner.tsx
rename to src/components/modules/note/NoteBanner.tsx
diff --git a/src/components/widgets/note/NoteFontFab.tsx b/src/components/modules/note/NoteFontFab.tsx
similarity index 100%
rename from src/components/widgets/note/NoteFontFab.tsx
rename to src/components/modules/note/NoteFontFab.tsx
diff --git a/src/components/widgets/note/NoteFooterNavigation.tsx b/src/components/modules/note/NoteFooterNavigation.tsx
similarity index 100%
rename from src/components/widgets/note/NoteFooterNavigation.tsx
rename to src/components/modules/note/NoteFooterNavigation.tsx
diff --git a/src/components/widgets/note/NoteHeadCover.tsx b/src/components/modules/note/NoteHeadCover.tsx
similarity index 97%
rename from src/components/widgets/note/NoteHeadCover.tsx
rename to src/components/modules/note/NoteHeadCover.tsx
index a4e52253fa..1a84d79808 100644
--- a/src/components/widgets/note/NoteHeadCover.tsx
+++ b/src/components/modules/note/NoteHeadCover.tsx
@@ -3,7 +3,7 @@
import { useLayoutEffect, useState } from 'react'
import clsx from 'clsx'
-import { AutoResizeHeight } from '~/components/widgets/shared/AutoResizeHeight'
+import { AutoResizeHeight } from '~/components/modules/shared/AutoResizeHeight'
function cropImageTo16by9(src: string): Promise {
return new Promise((resolve, reject) => {
diff --git a/src/components/widgets/note/NoteHideIfSecret.tsx b/src/components/modules/note/NoteHideIfSecret.tsx
similarity index 100%
rename from src/components/widgets/note/NoteHideIfSecret.tsx
rename to src/components/modules/note/NoteHideIfSecret.tsx
diff --git a/src/components/widgets/note/NoteLeftSidebar.tsx b/src/components/modules/note/NoteLeftSidebar.tsx
similarity index 100%
rename from src/components/widgets/note/NoteLeftSidebar.tsx
rename to src/components/modules/note/NoteLeftSidebar.tsx
diff --git a/src/components/widgets/note/NoteMainContainer.tsx b/src/components/modules/note/NoteMainContainer.tsx
similarity index 100%
rename from src/components/widgets/note/NoteMainContainer.tsx
rename to src/components/modules/note/NoteMainContainer.tsx
diff --git a/src/components/widgets/note/NoteMetaBar.tsx b/src/components/modules/note/NoteMetaBar.tsx
similarity index 100%
rename from src/components/widgets/note/NoteMetaBar.tsx
rename to src/components/modules/note/NoteMetaBar.tsx
diff --git a/src/components/widgets/note/NotePasswordForm.tsx b/src/components/modules/note/NotePasswordForm.tsx
similarity index 100%
rename from src/components/widgets/note/NotePasswordForm.tsx
rename to src/components/modules/note/NotePasswordForm.tsx
diff --git a/src/components/widgets/note/NoteTimeline.tsx b/src/components/modules/note/NoteTimeline.tsx
similarity index 100%
rename from src/components/widgets/note/NoteTimeline.tsx
rename to src/components/modules/note/NoteTimeline.tsx
diff --git a/src/components/widgets/note/NoteTopic.tsx b/src/components/modules/note/NoteTopic.tsx
similarity index 98%
rename from src/components/widgets/note/NoteTopic.tsx
rename to src/components/modules/note/NoteTopic.tsx
index 08c1053ef6..77f926a809 100644
--- a/src/components/widgets/note/NoteTopic.tsx
+++ b/src/components/modules/note/NoteTopic.tsx
@@ -35,6 +35,7 @@ export const NoteTopic: FC = () => {
{
const { present } = useModalStack()
diff --git a/src/components/widgets/post/fab/PostsSortingFab.tsx b/src/components/modules/post/fab/PostsSortingFab.tsx
similarity index 100%
rename from src/components/widgets/post/fab/PostsSortingFab.tsx
rename to src/components/modules/post/fab/PostsSortingFab.tsx
diff --git a/src/components/widgets/post/index.ts b/src/components/modules/post/index.ts
similarity index 100%
rename from src/components/widgets/post/index.ts
rename to src/components/modules/post/index.ts
diff --git a/src/components/widgets/project/ProjectIcon.tsx b/src/components/modules/project/ProjectIcon.tsx
similarity index 100%
rename from src/components/widgets/project/ProjectIcon.tsx
rename to src/components/modules/project/ProjectIcon.tsx
diff --git a/src/components/widgets/project/ProjectList.tsx b/src/components/modules/project/ProjectList.tsx
similarity index 100%
rename from src/components/widgets/project/ProjectList.tsx
rename to src/components/modules/project/ProjectList.tsx
diff --git a/src/components/widgets/shared/AccentColorStyleInjector.tsx b/src/components/modules/shared/AccentColorStyleInjector.tsx
similarity index 99%
rename from src/components/widgets/shared/AccentColorStyleInjector.tsx
rename to src/components/modules/shared/AccentColorStyleInjector.tsx
index bc71f1a227..023ad84d1e 100644
--- a/src/components/widgets/shared/AccentColorStyleInjector.tsx
+++ b/src/components/modules/shared/AccentColorStyleInjector.tsx
@@ -1,5 +1,4 @@
import Color from 'colorjs.io'
-import type { AccentColor } from '~/app/config'
import type { FC } from 'react'
const hexToOklchString = (hex: string) => {
diff --git a/src/components/widgets/shared/ActionAsideContainer.tsx b/src/components/modules/shared/ActionAsideContainer.tsx
similarity index 100%
rename from src/components/widgets/shared/ActionAsideContainer.tsx
rename to src/components/modules/shared/ActionAsideContainer.tsx
diff --git a/src/components/widgets/shared/ArticleRightAside.tsx b/src/components/modules/shared/ArticleRightAside.tsx
similarity index 100%
rename from src/components/widgets/shared/ArticleRightAside.tsx
rename to src/components/modules/shared/ArticleRightAside.tsx
diff --git a/src/components/widgets/shared/AsideCommentButton.tsx b/src/components/modules/shared/AsideCommentButton.tsx
similarity index 93%
rename from src/components/widgets/shared/AsideCommentButton.tsx
rename to src/components/modules/shared/AsideCommentButton.tsx
index 047df14a86..f63773f44b 100644
--- a/src/components/widgets/shared/AsideCommentButton.tsx
+++ b/src/components/modules/shared/AsideCommentButton.tsx
@@ -1,8 +1,8 @@
import type { CommentModalProps } from './CommentModal'
import { MotionButtonBase } from '~/components/ui/button'
+import { useModalStack } from '~/components/ui/modal'
import { useIsClient } from '~/hooks/common/use-is-client'
-import { useModalStack } from '~/providers/root/modal-stack-provider'
import { ActionAsideIcon } from './ActionAsideContainer'
import { CommentModal } from './CommentModal'
diff --git a/src/components/widgets/shared/AsideDonateButton.tsx b/src/components/modules/shared/AsideDonateButton.tsx
similarity index 100%
rename from src/components/widgets/shared/AsideDonateButton.tsx
rename to src/components/modules/shared/AsideDonateButton.tsx
diff --git a/src/components/widgets/shared/AutoResizeHeight.tsx b/src/components/modules/shared/AutoResizeHeight.tsx
similarity index 100%
rename from src/components/widgets/shared/AutoResizeHeight.tsx
rename to src/components/modules/shared/AutoResizeHeight.tsx
diff --git a/src/components/widgets/shared/BanCopyWrapper.tsx b/src/components/modules/shared/BanCopyWrapper.tsx
similarity index 100%
rename from src/components/widgets/shared/BanCopyWrapper.tsx
rename to src/components/modules/shared/BanCopyWrapper.tsx
diff --git a/src/components/widgets/shared/CodeBlock.tsx b/src/components/modules/shared/CodeBlock.tsx
similarity index 100%
rename from src/components/widgets/shared/CodeBlock.tsx
rename to src/components/modules/shared/CodeBlock.tsx
diff --git a/src/components/widgets/shared/CommentModal.tsx b/src/components/modules/shared/CommentModal.tsx
similarity index 89%
rename from src/components/widgets/shared/CommentModal.tsx
rename to src/components/modules/shared/CommentModal.tsx
index d239bb9179..90603eff71 100644
--- a/src/components/widgets/shared/CommentModal.tsx
+++ b/src/components/modules/shared/CommentModal.tsx
@@ -1,4 +1,4 @@
-import type { ModalContentComponent } from '~/providers/root/modal-stack-provider'
+import type { ModalContentComponent } from '~/components/ui/modal'
import { CommentBoxRootLazy, CommentsLazy } from '../comment'
diff --git a/src/components/modules/shared/DeleteConfirmButton.tsx b/src/components/modules/shared/DeleteConfirmButton.tsx
new file mode 100644
index 0000000000..02f3e1b981
--- /dev/null
+++ b/src/components/modules/shared/DeleteConfirmButton.tsx
@@ -0,0 +1,51 @@
+import type { FC, PropsWithChildren } from 'react'
+
+import { MotionButtonBase, StyledButton } from '~/components/ui/button'
+import { FloatPopover } from '~/components/ui/float-popover'
+import { toast } from '~/lib/toast'
+
+export const DeleteConfirmButton: FC<
+ {
+ onDelete: () => Promise
+ confirmText?: string
+ deleteItemText?: string
+ } & PropsWithChildren
+> = (props) => {
+ const { onDelete, confirmText, deleteItemText } = props
+
+ const defaultButton = (
+ {
+ onDelete().then(() => {
+ toast.success('删除成功')
+ })
+ }}
+ >
+ 确定
+
+ )
+
+ return (
+
+ 删除
+
+ }
+ >
+
+
+ {confirmText ??
+ (deleteItemText
+ ? `确定删除「${deleteItemText}」吗?`
+ : '确定删除吗?')}
+
+
+ {props.children || defaultButton}
+
+ )
+}
diff --git a/src/components/widgets/shared/EmbedGithubFile.tsx b/src/components/modules/shared/EmbedGithubFile.tsx
similarity index 100%
rename from src/components/widgets/shared/EmbedGithubFile.tsx
rename to src/components/modules/shared/EmbedGithubFile.tsx
diff --git a/src/components/widgets/shared/EmojiPicker.tsx b/src/components/modules/shared/EmojiPicker.tsx
similarity index 100%
rename from src/components/widgets/shared/EmojiPicker.tsx
rename to src/components/modules/shared/EmojiPicker.tsx
diff --git a/src/components/modules/shared/Empty.tsx b/src/components/modules/shared/Empty.tsx
new file mode 100644
index 0000000000..5a879ef925
--- /dev/null
+++ b/src/components/modules/shared/Empty.tsx
@@ -0,0 +1,433 @@
+import { clsxm } from '~/lib/helper'
+
+export const Empty: Component = ({ className }) => {
+ return (
+
+ <$404SVG className="h-[400px] w-[400px]" />
+
+ 在这个星球上还没有知识,去其他地方探索吧。
+
+
+ )
+}
+
+const $404SVG: Component = ({ className }) => {
+ return (
+
+ )
+}
diff --git a/src/components/widgets/shared/GoToAdminEditingButton.tsx b/src/components/modules/shared/GoToAdminEditingButton.tsx
similarity index 100%
rename from src/components/widgets/shared/GoToAdminEditingButton.tsx
rename to src/components/modules/shared/GoToAdminEditingButton.tsx
diff --git a/src/components/widgets/shared/LoadMoreIndicator.tsx b/src/components/modules/shared/LoadMoreIndicator.tsx
similarity index 100%
rename from src/components/widgets/shared/LoadMoreIndicator.tsx
rename to src/components/modules/shared/LoadMoreIndicator.tsx
diff --git a/src/components/widgets/shared/Mermaid.tsx b/src/components/modules/shared/Mermaid.tsx
similarity index 100%
rename from src/components/widgets/shared/Mermaid.tsx
rename to src/components/modules/shared/Mermaid.tsx
diff --git a/src/components/widgets/shared/NothingFound.tsx b/src/components/modules/shared/NothingFound.tsx
similarity index 100%
rename from src/components/widgets/shared/NothingFound.tsx
rename to src/components/modules/shared/NothingFound.tsx
diff --git a/src/components/widgets/shared/PinIconToggle.tsx b/src/components/modules/shared/PinIconToggle.tsx
similarity index 95%
rename from src/components/widgets/shared/PinIconToggle.tsx
rename to src/components/modules/shared/PinIconToggle.tsx
index 0573f36e12..84ab4e8d7f 100644
--- a/src/components/widgets/shared/PinIconToggle.tsx
+++ b/src/components/modules/shared/PinIconToggle.tsx
@@ -41,7 +41,8 @@ export const PinIconToggle: Component<{
)
}
-function PhPushPinFill(props: SVGProps) {
+
+export function PhPushPinFill(props: SVGProps) {
return (