diff --git a/config/index.ts b/config/index.ts index 5ea052a..dd77f7a 100644 --- a/config/index.ts +++ b/config/index.ts @@ -9,8 +9,17 @@ export const KEY_META_CMS_METADATA_SEED = 'META_CMS_METADATA_SEED'; export const KEY_META_CMS_METADATA_PUBLIC_KEYS = 'META_CMS_METADATA_PUBLIC_KEYS'; export const KEY_META_CMS_GATEWAY_CHECKED = 'META_CMS_GATEWAY_CHECKED'; +// Gun Store Key +export const KEY_META_CMS_GUN_SEED = 'META_CMS_GUN_SEED'; +export const KEY_META_CMS_GUN_PAIR = 'META_CMS_GUN_PAIR'; + export const GITHUB_URL = 'https://github.com'; // OSS link export const OSS_MATATAKI_FEUSE = 'https://ssimg.frontenduse.top'; export const OSS_MATATAKI = 'https://smartsignature-img.oss-cn-hongkong.aliyuncs.com'; + +// Gun Config +export const GUN_PEERS = ['https://gun-manhattan.herokuapp.com/gun']; +export const KEY_GUN_ROOT = 'meta.io_v7'; +export const KEY_GUN_ROOT_DRAFT = 'cms_draft'; diff --git a/package.json b/package.json index f3b4444..946f96e 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "classnames": "^2.2.6", "dexie": "^3.0.3", "dexie-react-hooks": "^1.0.7", + "gun": "^0.2020.1235", "is-mobile": "^3.0.0", "isomorphic-form-data": "^2.0.0", "lodash": "^4.17.11", diff --git a/src/app.tsx b/src/app.tsx index a80abb7..98b3d0c 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -14,6 +14,8 @@ import PublishSiteButton from './components/menu/PublishSiteButton'; import { queryCurrentUser, queryInvitations, refreshTokens } from './services/api/meta-ucenter'; import type { SiderMenuProps } from '@ant-design/pro-layout/lib/components/SiderMenu/SiderMenu'; import { getDefaultSiteConfigAPI } from './helpers/index'; +import { FetchPostsStorageParamsState } from './services/constants'; +import MenuFeedbackButton from './components/menu/MenuFeedbackButton'; const { Text } = Typography; @@ -93,25 +95,26 @@ export async function getInitialState(): Promise { const publishedCountRequest = await fetchPostsStorage(siteConfig?.id, { page: 1, limit: 1, - draft: false, + state: FetchPostsStorageParamsState.Published, }); publishedCount = publishedCountRequest?.data?.meta?.totalItems || 0; } - // local draft count - const localDraftCount = await dbPostsAllCount(); - const state: GLOBAL.InitialState = { fetchUserInfo, invitationsCount, publishedCount, - localDraftCount, + localDraftCount: 0, siteConfig, }; if (history.location.pathname !== '/user/login') { const currentUser = await fetchUserInfo(); - if (currentUser) state.currentUser = currentUser; + if (currentUser) { + state.currentUser = currentUser; + // local draft count + state.localDraftCount = await dbPostsAllCount(currentUser!.id); + } } if (isMobile()) { @@ -193,6 +196,10 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => { history.push('/user/login'); } }, - links: [, ], + links: [ + , + , + , + ], }; }; diff --git a/src/components/Editor/settingsCopyrightNotice.tsx b/src/components/Editor/settingsCopyrightNotice.tsx index 4620fd4..e1612ce 100644 --- a/src/components/Editor/settingsCopyrightNotice.tsx +++ b/src/components/Editor/settingsCopyrightNotice.tsx @@ -205,7 +205,7 @@ const SettingsCopyrightNotice: FC = ({ license, handleChangeLicense }) => {intl.formatMessage({ id: 'editor.license.creativeCommons.sa' })}  diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx new file mode 100644 index 0000000..16ebe0b --- /dev/null +++ b/src/components/Icon/index.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import Icon from '@ant-design/icons'; + +const MatrixSvg = () => ( + + + + + +); +export const MatrixIcon: React.FC = (props) => ; + +const TelegramSvg = () => ( + + + +); +export const TelegramIcon: React.FC = (props) => ; + +const DiscordSvg = () => ( + + + + +); +export const DiscordIcon: React.FC = (props) => ; +const MetaLogoSvg = () => ( + + + + +); +export const MetaLogoIcon: React.FC = (props) => ; + +const ElementSvg = () => ( + + + +); +export const ElementIcon: React.FC = (props) => ; diff --git a/src/components/dashboard/SubmmitedPostsTable/index.tsx b/src/components/dashboard/SubmmitedPostsTable/index.tsx index f3489a3..4106915 100644 --- a/src/components/dashboard/SubmmitedPostsTable/index.tsx +++ b/src/components/dashboard/SubmmitedPostsTable/index.tsx @@ -1,13 +1,17 @@ -import { fetchPostsPublished } from '@/services/api/meta-cms'; +import { useState } from 'react'; +import { fetchPostsStorage } from '@/services/api/meta-cms'; import { FormattedMessage, useIntl } from 'umi'; import type { ProColumns } from '@ant-design/pro-table'; import ProTable from '@ant-design/pro-table'; import { Button, Image, Space, Typography } from 'antd'; import { LinkOutlined } from '@ant-design/icons'; import { generateDataViewerLink } from '@/utils/editor'; +import { FetchPostsStorageParamsState } from '@/services/constants'; +import { getDefaultSiteConfigAPI } from '@/helpers'; export default () => { const intl = useIntl(); + const [siteConfigDefault, setSiteConfigDefault] = useState(); const columns: ProColumns[] = [ { @@ -108,15 +112,31 @@ export default () => { }, ]; - async function fetchPostsSubmitted(page: number, limit: number) { - return await fetchPostsPublished(page, limit); - } - return ( columns={columns} request={async ({ pageSize, current }) => { - const request = await fetchPostsSubmitted(current ?? 1, pageSize ?? 10); + let _siteConfigDefault: CMS.SiteConfiguration | undefined; + + if (!siteConfigDefault?.id) { + _siteConfigDefault = await getDefaultSiteConfigAPI(); + if (_siteConfigDefault) { + setSiteConfigDefault(_siteConfigDefault); + } else { + return { success: false }; + } + } + + const params = { + page: current ?? 1, + limit: pageSize ?? 10, + state: FetchPostsStorageParamsState.Posted, + }; + const request = await fetchPostsStorage( + siteConfigDefault?.id || _siteConfigDefault!.id, + params, + ); + // 这里需要返回一个 Promise,在返回之前你可以进行数据转化 // 如果需要转化参数可以在这里进行修改 if (request?.data) { diff --git a/src/components/menu/MenuFeedbackButton/index.tsx b/src/components/menu/MenuFeedbackButton/index.tsx new file mode 100644 index 0000000..839eb57 --- /dev/null +++ b/src/components/menu/MenuFeedbackButton/index.tsx @@ -0,0 +1,18 @@ +import { MatrixIcon } from '../../Icon/index'; +import { Tooltip } from 'antd'; + +const feedbackLink = 'https://forms.gle/1HAZ8puQ9vhBSqMGA'; + +export default () => { + return ( +
{ + window.open(feedbackLink, '_blank'); + }} + > + + + +
+ ); +}; diff --git a/src/components/menu/MenuLanguageSwitch/index.tsx b/src/components/menu/MenuLanguageSwitch/index.tsx index 9c6820e..f15c628 100644 --- a/src/components/menu/MenuLanguageSwitch/index.tsx +++ b/src/components/menu/MenuLanguageSwitch/index.tsx @@ -1,7 +1,8 @@ +import { setLocale } from 'umi'; +import { useState } from 'react'; import { GlobalOutlined } from '@ant-design/icons'; import { Dropdown, Menu, Typography } from 'antd'; import style from './index.less'; -import { setLocale } from 'umi'; const languages = [ { @@ -29,13 +30,25 @@ const menu = ( ); -export default () => ( - - - -); +export default () => { + const [visible, setVisible] = useState(false); + + return ( +
{ + setVisible((v) => !v); + return; + }} + > + setVisible(isVisible)} + overlayClassName={style.menuLanguageSwitch} + > + + +
+ ); +}; diff --git a/src/components/menu/MenuMoreInfo/index.tsx b/src/components/menu/MenuMoreInfo/index.tsx index 87ae443..998b00f 100644 --- a/src/components/menu/MenuMoreInfo/index.tsx +++ b/src/components/menu/MenuMoreInfo/index.tsx @@ -1,96 +1,126 @@ +import { Dropdown, Menu } from 'antd'; +import { Fragment, useState } from 'react'; +import { FormattedMessage } from 'umi'; import { - GithubOutlined, - LinkOutlined, + TwitterOutlined, MediumOutlined, + LinkOutlined, QuestionOutlined, - TwitterOutlined, + YoutubeOutlined, } from '@ant-design/icons'; -import { Dropdown, Menu } from 'antd'; -import { FormattedMessage } from 'umi'; +import { TelegramIcon, DiscordIcon, ElementIcon, MetaLogoIcon } from '../../Icon/index'; import style from './index.less'; +const menuJson = [ + { + title: , + item: [ + { + url: 'https://matrix.to/#/!jrjmzTFiYYIuKnRpEg:matrix.org?via=matrix.org', + icon: , + name: 'Matrix Group', + }, + { + url: 'https://discord.com/invite/59cXXWCWUT', + icon: , + name: 'Discord', + }, + { + url: 'https://t.me/metanetwork', + icon: , + name: 'Telegram', + }, + { + url: 'https://twitter.com/realMetaNetwork', + icon: , + name: 'Twitter', + }, + { + url: 'https://medium.com/meta-network', + icon: , + name: 'Medium', + }, + { + url: 'https://www.youtube.com/channel/UC-rNon6FUm3blTnSrXta2gw', + icon: , + name: 'Youtube', + }, + ], + }, + { + title: , + item: [ + { + url: 'https://www.meta.io', + icon: , + name: 'Meta.io', + }, + { + url: 'https://www.matataki.io', + icon: , + name: 'Matataki', + }, + { + url: 'https://home.metanetwork.online', + icon: , + name: 'home', + }, + ], + }, + { + title: , + item: [ + { + url: 'https://metanetwork.online/terms', + icon: , + name: , + }, + { + url: 'https://metanetwork.online/privacy', + icon: , + name: , + }, + ], + }, +]; + const menu = ( - - - - }> - - Twitter - - - }> - - Telegram - - - }> - - Discord - - - }> - - Medium - - - }> - - Github - - - - - - - - - - - - - - - - - - Meta.io - - - - - Meta Space - - - - - Meta Network - - - - - - - - - - - - - - - - + {menuJson.map((i, idx) => ( + + {i.title} + {i.item.map((j, idxJ) => ( + + + {j.name} + + + ))} + {idx < menuJson.length - 1 ? : null} + + ))} ); -export default () => ( - - - -); +export default () => { + const [visible, setVisible] = useState(false); + + return ( +
{ + setVisible((v) => !v); + return; + }} + > + setVisible(isVisible)} + overlayClassName={style.menuMoreInfo} + > + + +
+ ); +}; diff --git a/src/db/Posts.d.ts b/src/db/Posts.d.ts index bf89aea..36c4c65 100644 --- a/src/db/Posts.d.ts +++ b/src/db/Posts.d.ts @@ -12,6 +12,7 @@ export interface Posts { draft: CMS.Draft | null; tags: string[]; license: string; + userId: number; createdAt: string; updatedAt: string; } diff --git a/src/db/db.ts b/src/db/db.ts index e5b3623..7bb744b 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -10,10 +10,10 @@ export class StoreDB extends Dexie { metadatas!: Table; constructor() { super('StoreDB'); - this.version(11) + this.version(12) .stores({ posts: - '++id, cover, title, summary, content, hash, status, timestamp, delete, post, draft, tags, license, createdAt, updatedAt', + '++id, cover, title, summary, content, hash, status, timestamp, delete, post, draft, tags, license, userId ,createdAt, updatedAt', metadatas: '++id, postId, metadata, delete, createdAt, updatedAt', }) .upgrade((tx: Transaction | any) => { @@ -33,6 +33,7 @@ export class StoreDB extends Dexie { post.draft = post.draft || null; post.tags = post.tags || []; post.license = post.license || License; + post.userId = post.userId || 0; post.createdAt = post.createdAt || time; post.updatedAt = post.updatedAt || time; }); @@ -91,25 +92,29 @@ export const dbPostsDeleteAll = async (): Promise => { /** * db posts all + * @param userId * @returns */ -export const dbPostsAll = async (): Promise => { +export const dbPostsAll = async (userId: number): Promise => { return await db.posts - .filter((i) => !i.delete) + .filter((i) => !i.delete && i.userId === userId) .reverse() .sortBy('updatedAt'); }; /** * db posts all counter + * @param userId * @returns */ -export const dbPostsAllCount = async (): Promise => { - return await db.posts.filter((i) => !i.delete).count(); +export const dbPostsAllCount = async (userId: number): Promise => { + return await db.posts.filter((i) => !i.delete && i.userId === userId).count(); }; /** * db posts where exist by id + * @param id + * @returns */ export const dbPostsWhereExist = async (id: number): Promise => { // 草稿删除了 允许重新编辑 @@ -137,6 +142,7 @@ export const PostTempData = (): Posts => ({ draft: null, tags: [], license: License, + userId: 0, createdAt: moment().toISOString(), updatedAt: moment().toISOString(), }); diff --git a/src/global.tsx b/src/global.tsx index 5965146..51fcb6b 100644 --- a/src/global.tsx +++ b/src/global.tsx @@ -1,10 +1,15 @@ import { Button, message, notification } from 'antd'; import { useIntl } from 'umi'; import defaultSettings from '../config/defaultSettings'; +import { initGun } from './utils/gun'; const { pwa } = defaultSettings; const isHttps = document.location.protocol === 'https:'; +if (!(window as any).gun) { + initGun(); +} + // if pwa is true if (pwa) { // Notify user if offline now diff --git a/src/locales/en-US/menu.ts b/src/locales/en-US/menu.ts index 2064fdd..56d65e9 100644 --- a/src/locales/en-US/menu.ts +++ b/src/locales/en-US/menu.ts @@ -22,7 +22,7 @@ export default { 'menu.moreInfo.policy': 'Policy', 'menu.moreInfo.terms': 'Terms', 'menu.moreInfo.privacyPolicy': 'Privacy Policy', - 'menu.moreInfo.versions': 'Version record', + 'menu.moreInfo.home': 'Home', 'menu.post.create': 'Create', 'menu.settings': 'Settings', }; diff --git a/src/locales/zh-CN/menu.ts b/src/locales/zh-CN/menu.ts index 849199f..686bb7a 100644 --- a/src/locales/zh-CN/menu.ts +++ b/src/locales/zh-CN/menu.ts @@ -21,7 +21,7 @@ export default { 'menu.moreInfo.policy': '政策', 'menu.moreInfo.terms': '条款', 'menu.moreInfo.privacyPolicy': '隐私政策', - 'menu.moreInfo.versions': '版本记录', + 'menu.moreInfo.home': '首页', 'menu.post.create': '创作', 'menu.settings': '设置', }; diff --git a/src/pages/Settings/components/DeleteLocalDraft/index.less b/src/pages/Settings/components/DeleteLocalDraft/index.less new file mode 100644 index 0000000..f134448 --- /dev/null +++ b/src/pages/Settings/components/DeleteLocalDraft/index.less @@ -0,0 +1,8 @@ +.list { + max-width: 600px; + :global { + .ant-list-item-meta-avatar .anticon { + font-size: 22px; + } + } +} diff --git a/src/pages/Settings/components/DeleteLocalDraft/index.tsx b/src/pages/Settings/components/DeleteLocalDraft/index.tsx index fdb38be..ba8a464 100644 --- a/src/pages/Settings/components/DeleteLocalDraft/index.tsx +++ b/src/pages/Settings/components/DeleteLocalDraft/index.tsx @@ -1,52 +1,183 @@ -import { useCallback } from 'react'; -import { useIntl } from 'umi'; -import { Typography, Button, Popconfirm, message, Space } from 'antd'; +import { useCallback, useMemo, useState } from 'react'; +import { useIntl, useModel } from 'umi'; +import { Typography, Button, Popconfirm, message, Space, Input, List, Dropdown } from 'antd'; +import { DeleteOutlined, CloudSyncOutlined } from '@ant-design/icons'; import { dbPostsDeleteAll, dbMetadatasDeleteAll } from '@/db/db'; +import { fetchGunDraftsAndUpdateLocal, deleteDraft, twoWaySyncDrafts } from '@/utils/gun'; +import { storeGet, storeSet } from '@/utils/store'; +import { KEY_META_CMS_GUN_SEED, KEY_META_CMS_GUN_PAIR } from '../../../../../config'; +import styles from './index.less'; +import type { KeyPair } from '@metaio/meta-signature-util'; +import { generateKeys } from '@metaio/meta-signature-util'; const { Text } = Typography; +const ImportSeedAndPairComponents = () => { + const [seedAndPairInput, setSeedAndPairInput] = useState(''); + // handle import + const handleImport = useCallback(() => { + if (!seedAndPairInput) { + return; + } + + const [seed, pair] = JSON.parse(seedAndPairInput); + storeSet(KEY_META_CMS_GUN_SEED, seed); + storeSet(KEY_META_CMS_GUN_PAIR, pair); + + message.success('导入成功'); + }, [seedAndPairInput]); + return ( + + setSeedAndPairInput(e.target.value)} value={seedAndPairInput} /> + + + ); +}; + export default () => { const intl = useIntl(); + const { initialState } = useModel('@@initialState'); + const [syncDraftsLoading, setSyncDraftsLoading] = useState(false); /** * handle delete all local draft */ const handleDeleteAllLocalDraft = useCallback(async () => { + if (!initialState?.currentUser) { + return; + } + + // 删除本地所有文章 mettadata 数据 await dbPostsDeleteAll(); await dbMetadatasDeleteAll(); + + // 删除用户的 gun 文章 + const gunDraftsResult = await fetchGunDraftsAndUpdateLocal(initialState.currentUser); + + for (let i = 0; i < gunDraftsResult.length; i++) { + const ele = gunDraftsResult[i]; + if (ele.key) { + await deleteDraft({ userId: initialState.currentUser.id, key: ele.key }); + } + } + message.success( intl.formatMessage({ id: 'messages.delete.success', }), ); - }, [intl]); + }, [intl, initialState]); + + // two way sync drafts + const twoWaySyncDraftsFn = useCallback(async () => { + if (!initialState?.currentUser) { + return; + } + + setSyncDraftsLoading(true); + await twoWaySyncDrafts(initialState.currentUser); + setSyncDraftsLoading(false); + }, [initialState]); + + // sync seed and pair + const seedAndPair = useMemo(() => { + // TODO: 复制出来的格式并不好看,可以考虑加密成一串字符然后导入再解密 待考虑 + const seed = storeGet(KEY_META_CMS_GUN_SEED); + const pair = storeGet(KEY_META_CMS_GUN_PAIR); + return JSON.stringify([seed, pair]); + }, []); + + // sync seed public key + const seedPublicKey = useMemo(() => { + const seed = JSON.parse(storeGet(KEY_META_CMS_GUN_SEED) || '[]'); + if (!seed.length) { + return ''; + } + const keys: KeyPair = generateKeys(seed); + return keys.public; + }, []); + + const list = useMemo( + () => [ + { + name: 'deleteDraft', + title: '删除本地草稿', + description: '删除本地所有草稿,包含本地其他用户的草稿。', + icon: , + actions: [ + + + , + ], + }, + { + name: 'syncDraft', + title: '草稿同步', + description: '可以复制 Seed & Pair 到其他需要同步的设备使用。', + icon: , + actions: [ + + {seedPublicKey.slice(0, 6)}****{seedPublicKey.slice(-4)} + , + } trigger={['click']}> + + , + + + , + ], + }, + ], + [ + handleDeleteAllLocalDraft, + twoWaySyncDraftsFn, + intl, + seedAndPair, + seedPublicKey, + syncDraftsLoading, + ], + ); return ( - - - {intl.formatMessage({ - id: 'setting.deleteLocalDraft.all', - })} - - - - - + ( + + + + )} + /> ); }; diff --git a/src/pages/content/PublishedPosts.tsx b/src/pages/content/PublishedPosts.tsx index 1a37568..ffedbc5 100644 --- a/src/pages/content/PublishedPosts.tsx +++ b/src/pages/content/PublishedPosts.tsx @@ -7,6 +7,7 @@ import { fetchPostsStorage } from '@/services/api/meta-cms'; import type { ProColumns, ActionType } from '@ant-design/pro-table'; import FormattedDescription from '@/components/FormattedDescription'; import { getDefaultSiteConfigAPI } from '@/helpers'; +import { FetchPostsStorageParamsState } from '@/services/constants'; export default () => { const intl = useIntl(); @@ -137,7 +138,7 @@ export default () => { const params = { page: current ?? 1, limit: pageSize ?? 10, - draft: false, + state: FetchPostsStorageParamsState.Published, }; const request = await fetchPostsStorage( siteConfigDefault?.id || _siteConfigDefault!.id, diff --git a/src/pages/content/SyncCenter.tsx b/src/pages/content/SyncCenter.tsx index 555c126..b50e351 100644 --- a/src/pages/content/SyncCenter.tsx +++ b/src/pages/content/SyncCenter.tsx @@ -5,6 +5,7 @@ import { fetchPostSync, getSourceStatus, publishPosts, + decryptMatatakiPost, } from '@/services/api/meta-cms'; import { useIntl, useModel, history } from 'umi'; import ProTable from '@ant-design/pro-table'; @@ -20,6 +21,8 @@ import { imageUploadByUrlAPI } from '@/helpers'; import styles from './SyncCenter.less'; import { fetchIpfs } from '@/services/api/global'; import { OSS_MATATAKI, OSS_MATATAKI_FEUSE } from '../../../config'; +import { useMount } from 'ahooks'; +import { queryCurrentUser } from '@/services/api/meta-ucenter'; const { confirm } = Modal; @@ -33,14 +36,11 @@ export default () => { const [transferDraftLoading, setTransferDraftLoading] = useState(false); const { getLockedConfigState, setLockedConfig } = useModel('global'); const { setSiteNeedToDeploy } = useModel('storage'); - // const [siteConfiguration, setSiteConfiguration] = useState( - // {} as CMS.SiteConfiguration, - // ); + const [currentUser, setCurrentUser] = useState(); getDefaultSiteConfig().then((response) => { if (response.statusCode === 200) { setSiteConfigId(response.data.id); - // setSiteConfiguration(response.data); } }); @@ -91,6 +91,10 @@ export default () => { */ const transferDraft = useCallback( async (post: CMS.Post) => { + if (!currentUser?.id) { + return; + } + setTransferDraftLoading(true); // check save as draft @@ -127,7 +131,11 @@ export default () => { } try { - const postResult: { content: string } = await fetchIpfs(_post.source); + let postResult = await fetchIpfs(_post.source); + if (!postResult.content && postResult.iv && postResult.encryptedData) { + postResult = await decryptMatatakiPost(postResult.iv, postResult.encryptedData); + } + if (!postResult.content) { message.success(intl.formatMessage({ id: 'messages.syncCenter.getContentFail' })); return; @@ -143,6 +151,7 @@ export default () => { post: _post, tags: _post.tags || [], license: '', + userId: currentUser?.id, }), ); @@ -154,9 +163,21 @@ export default () => { setTransferDraftLoading(false); } }, - [intl], + [intl, currentUser], ); + /** fetch current user */ + const fetchCurrentUser = useCallback(async () => { + const result = await queryCurrentUser(); + if (result.statusCode === 200) { + setCurrentUser(result.data); + } + }, []); + + useMount(() => { + fetchCurrentUser(); + }); + const columns: ProColumns[] = [ { dataIndex: 'cover', diff --git a/src/pages/content/drafts/Edit.tsx b/src/pages/content/drafts/Edit.tsx index 57b3e30..17d9883 100644 --- a/src/pages/content/drafts/Edit.tsx +++ b/src/pages/content/drafts/Edit.tsx @@ -16,7 +16,7 @@ import { } from '@/db/db'; import type { Posts } from '@/db/Posts.d'; import { imageUploadByUrlAPI, getDefaultSiteConfigAPI } from '@/helpers'; -import { assign } from 'lodash'; +import { assign, cloneDeep } from 'lodash'; // import type Vditor from 'vditor'; import { uploadMetadata, generateSummary, postDataMergedUpdateAt } from '@/utils/editor'; import FullLoading from '@/components/FullLoading'; @@ -32,29 +32,174 @@ import type { PostMetadata } from '@metaio/meta-signature-util'; import { postStoragePublish, postStorageUpdate } from '@/services/api/meta-cms'; import { mergedMessage } from '@/utils'; import moment from 'moment'; -import { OSS_MATATAKI, OSS_MATATAKI_FEUSE } from '../../../../config'; +import { + OSS_MATATAKI, + OSS_MATATAKI_FEUSE, + KEY_GUN_ROOT, + KEY_GUN_ROOT_DRAFT, + KEY_META_CMS_GUN_PAIR, +} from '../../../../config'; import { DraftMode } from '@/services/constants'; +import Gun from 'gun'; +import { + fetchGunDraftsAndUpdateLocal, + syncNewDraft, + syncDraft, + fetchGunDrafts, + signIn, +} from '@/utils/gun'; +import { storeGet } from '@/utils/store'; const Edit: React.FC = () => { const intl = useIntl(); - // post data const [postData, setPostData] = useState({} as Posts); const [cover, setCover] = useState(''); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [tags, setTags] = useState([]); const [license, setLicense] = useState(''); - - // draft mode const [draftMode, setDraftMode] = useState(DraftMode.Default); // vditor // const [vditor, setVditor] = useState(); // 处理图片上传开关 const [flagImageUploadToIpfs, setFlagImageUploadToIpfs] = useState(false); - // publish loading const [publishLoading, setPublishLoading] = useState(false); const { setSiteNeedToDeploy } = useModel('storage'); + const { initialState } = useModel('@@initialState'); + + const postTempDataMergedUserId = useCallback( + () => assign(PostTempData(), { userId: initialState?.currentUser?.id }), + [initialState], + ); + + // watch current draft + const watchCurrentDraft = useCallback( + async (id: number) => { + if (!initialState?.currentUser?.id) { + return; + } + const draft = await dbPostsGet(id); + + if (!draft) { + return; + } + + const { currentUser } = initialState; + const userScope = `user_${currentUser.id}`; + const _gun = (window as any).gun.user().get(KEY_GUN_ROOT).get(KEY_GUN_ROOT_DRAFT); + + // 获取所有草稿找到 key + const gunAllDrafts = await fetchGunDrafts({ + gunDraft: _gun, + scope: userScope, + userId: currentUser.id, + }); + + const draftFind: any = gunAllDrafts.find( + (i) => String(i.timestamp) === String(draft.timestamp) && i.userId === currentUser.id, + ); + + const updateDraftFn = async (data: string) => { + const pair = JSON.parse(storeGet(KEY_META_CMS_GUN_PAIR) || '""'); + if (!pair) { + return; + } + + // 解密 + const msg = await Gun.SEA.verify(data, pair.pub); + const gunDraft = (await Gun.SEA.decrypt(msg, pair)) as Posts; + + // 如果文章变动 + if ( + moment(draft.updatedAt).isBefore(gunDraft.updatedAt) && + draft.userId === gunDraft.userId + ) { + // 监测到更新,立即本地更新 + const _data = assign(draft, gunDraft); + const updateData: any = cloneDeep(_data); + delete updateData.key; + + await dbPostsUpdate(updateData.id!, updateData); + } + }; + + if (draftFind && draftFind?.key) { + _gun + .get(userScope) + .get(draftFind.key) + .on((data: any) => { + if (data) { + updateDraftFn(data); + } + }); + } + }, + [initialState], + ); + + // handle history url state + const handleHistoryState = useCallback( + (id: string) => { + window.history.replaceState({}, '', `?id=${id}`); + history.location.query!.id = id; + + if (initialState?.currentUser?.id) { + // 同步新草稿 + syncNewDraft({ + id: Number(id), + userId: initialState.currentUser.id!, + }); + } + }, + [initialState], + ); + + // 处理更新 + const handleUpdate = useCallback( + async (id: number, data: any) => { + // local update + await dbPostsUpdate(id, data); + + // gun.js update + // 更新到 gun.js + if (!initialState?.currentUser?.id) { + return; + } + + const { currentUser } = initialState; + const draft = await dbPostsGet(id); + + const userScope = `user_${currentUser.id}`; + const _gun = (window as any).gun.user().get(KEY_GUN_ROOT).get(KEY_GUN_ROOT_DRAFT); + + const gunAllDrafts = await fetchGunDrafts({ + gunDraft: _gun, + scope: userScope, + userId: currentUser.id, + }); + + const draftFind: any = gunAllDrafts.find( + (i) => String(i.timestamp) === String(draft?.timestamp) && i.userId === currentUser.id, + ); + + // 更新草稿 + if (draftFind && draftFind?.key) { + syncDraft({ + userId: currentUser.id, + key: draftFind?.key, + data: draft, + }); + } else { + // 如果文章在 gun 被删了 + syncNewDraft({ + id: id, + userId: currentUser.id, + }); + } + }, + [initialState], + ); // upload metadata const uploadMetadataFn = useCallback( @@ -176,7 +321,7 @@ const Edit: React.FC = () => { }; const { id } = history.location.query as Router.PostQuery; - await dbPostsUpdate(Number(id), postDataMergedUpdateAt({ post: _post, draft: null })); + await handleUpdate(Number(id), postDataMergedUpdateAt({ post: _post, draft: null })); setSiteNeedToDeploy(true); history.push('/content/drafts'); @@ -193,7 +338,17 @@ const Edit: React.FC = () => { message.error(intl.formatMessage({ id: 'messages.editor.fail' })); } }, - [title, cover, content, tags, license, uploadMetadataFn, setSiteNeedToDeploy, intl], + [ + title, + cover, + content, + tags, + license, + uploadMetadataFn, + setSiteNeedToDeploy, + intl, + handleUpdate, + ], ); // publish @@ -237,7 +392,7 @@ const Edit: React.FC = () => { message.success(intl.formatMessage({ id: 'messages.editor.success' })); const { id } = history.location.query as Router.PostQuery; - await dbPostsUpdate( + await handleUpdate( Number(id), postDataMergedUpdateAt({ post: result.data[0], draft: null }), ); @@ -257,7 +412,17 @@ const Edit: React.FC = () => { message.error(intl.formatMessage({ id: 'messages.editor.fail' })); } }, - [title, cover, content, tags, license, uploadMetadataFn, setSiteNeedToDeploy, intl], + [ + title, + cover, + content, + tags, + license, + uploadMetadataFn, + setSiteNeedToDeploy, + handleUpdate, + intl, + ], ); // handle publish @@ -320,12 +485,6 @@ const Edit: React.FC = () => { [title, cover, content, postStorageUpdateFn, postStoragePublishFn, intl], ); - // handle history url state - const handleHistoryState = useCallback((id: string) => { - window.history.replaceState({}, '', `?id=${id}`); - history.location.query!.id = id; - }, []); - /** * async content to DB */ @@ -340,15 +499,15 @@ const Edit: React.FC = () => { summary: generateSummary(), }); if (id) { - await dbPostsUpdate(Number(id), data); + await handleUpdate(Number(id), data); } else { - const resultID = await dbPostsAdd(assign(PostTempData(), data)); + const resultID = await dbPostsAdd(assign(postTempDataMergedUserId(), data)); handleHistoryState(String(resultID)); } setDraftMode(DraftMode.Saved); }, - [handleHistoryState], + [handleHistoryState, postTempDataMergedUserId, handleUpdate], ); /** @@ -440,14 +599,14 @@ const Edit: React.FC = () => { const { id } = history.location.query as Router.PostQuery; const data = postDataMergedUpdateAt({ cover: url }); if (id) { - await dbPostsUpdate(Number(id), data); + await handleUpdate(Number(id), data); } else { - const resultID = await dbPostsAdd(assign(PostTempData(), data)); + const resultID = await dbPostsAdd(assign(postTempDataMergedUserId(), data)); handleHistoryState(String(resultID)); } setDraftMode(DraftMode.Saved); }, - [handleHistoryState], + [handleHistoryState, postTempDataMergedUserId, handleUpdate], ); /** @@ -458,9 +617,9 @@ const Edit: React.FC = () => { const { id } = history.location.query as Router.PostQuery; const data = postDataMergedUpdateAt({ title: val }); if (id) { - await dbPostsUpdate(Number(id), data); + await handleUpdate(Number(id), data); } else { - const resultID = await dbPostsAdd(assign(PostTempData(), data)); + const resultID = await dbPostsAdd(assign(postTempDataMergedUserId(), data)); handleHistoryState(String(resultID)); } }, @@ -493,15 +652,15 @@ const Edit: React.FC = () => { const { id } = history.location.query as Router.PostQuery; const data = postDataMergedUpdateAt({ tags: val }); if (id) { - await dbPostsUpdate(Number(id), data); + await handleUpdate(Number(id), data); } else { - const resultID = await dbPostsAdd(assign(PostTempData(), data)); + const resultID = await dbPostsAdd(assign(postTempDataMergedUserId(), data)); handleHistoryState(String(resultID)); } setDraftMode(DraftMode.Saved); }, - [handleHistoryState], + [handleHistoryState, postTempDataMergedUserId, handleUpdate], ); /** @@ -515,15 +674,15 @@ const Edit: React.FC = () => { const { id } = history.location.query as Router.PostQuery; const data = postDataMergedUpdateAt({ license: val }); if (id) { - await dbPostsUpdate(Number(id), data); + await handleUpdate(Number(id), data); } else { - const resultID = await dbPostsAdd(assign(PostTempData(), data)); + const resultID = await dbPostsAdd(assign(postTempDataMergedUserId(), data)); handleHistoryState(String(resultID)); } setDraftMode(DraftMode.Saved); }, - [handleHistoryState], + [handleHistoryState, postTempDataMergedUserId, handleUpdate], ); /** @@ -546,16 +705,31 @@ const Edit: React.FC = () => { // TODO:need modify setTimeout(() => { - (window as any).vditor!.setValue(resultPost.content); - // handle all image - handleImageUploadToIpfs(); + if ((window as any).vditor) { + (window as any).vditor!.setValue(resultPost.content); + // handle all image + handleImageUploadToIpfs(); + } }, 1000); } } }, [handleImageUploadToIpfs]); useMount(() => { - fetchDBContent(); + if (initialState?.currentUser) { + fetchGunDraftsAndUpdateLocal(initialState.currentUser).then(() => { + fetchDBContent(); + }); + + // 初始化监听 + const { id } = history.location.query as Router.PostQuery; + if (id) { + // 有草稿的监听 + signIn((window as any).gun).then(() => { + watchCurrentDraft(Number(id)); + }); + } + } }); useEffect(() => { diff --git a/src/pages/content/drafts/List.tsx b/src/pages/content/drafts/List.tsx index 765bd0e..4ae10d0 100644 --- a/src/pages/content/drafts/List.tsx +++ b/src/pages/content/drafts/List.tsx @@ -1,37 +1,47 @@ -import { useState, useCallback } from 'react'; -import { history, useIntl } from 'umi'; +import { useState, useCallback, useEffect } from 'react'; +import { history, useIntl, useModel } from 'umi'; import { PageContainer } from '@ant-design/pro-layout'; -import { useMount } from 'ahooks'; import { Table, Tag, Button, Image, Space, Popconfirm, message } from 'antd'; -import { dbPostsUpdate, dbPostsAll, dbMetadatasUpdateByPostId } from '@/db/db'; +import { dbPostsUpdate, dbMetadatasUpdateByPostId } from '@/db/db'; import type { Posts } from '@/db/Posts'; import { strSlice } from '@/utils'; +import { fetchGunDraftsAndUpdateLocal, deleteDraft } from '@/utils/gun'; +import type { GunDraft } from '@/utils/gun'; export default () => { const intl = useIntl(); - const [postsList, setPostsList] = useState([]); + const [postsList, setPostsList] = useState([]); + const { initialState } = useModel('@@initialState'); /** handle delete */ const handleDelete = useCallback( - async (id: number) => { + async (id: number, key?: string) => { await dbPostsUpdate(id, { delete: 1 }); await dbMetadatasUpdateByPostId(id, { delete: 1 }); + + if (key) { + deleteDraft({ + userId: initialState!.currentUser!.id, + key: key, + }); + } + message.success( intl.formatMessage({ id: 'posts.table.action.delete.success', }), ); }, - [intl], + [intl, initialState], ); /** fetch posts list */ const fetchPosts = useCallback(async () => { - const result = await dbPostsAll(); - if (result) { - setPostsList(result); + if (initialState?.currentUser) { + const response = await fetchGunDraftsAndUpdateLocal(initialState.currentUser); + setPostsList(response); } - }, []); + }, [initialState]); const columns = [ { @@ -101,7 +111,7 @@ export default () => { dataIndex: 'status', key: 'status', width: 180, - render: (val: string, record: Posts) => ( + render: (val: string, record: GunDraft) => ( {val === 'pending' ? (