feat: memo relation part1 (#1677)

* feat: memo relation part1

* chore: update
This commit is contained in:
boojack 2023-05-18 21:29:28 +08:00 committed by GitHub
parent ca5859296a
commit a07d5d38d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 425 additions and 207 deletions

View File

@ -160,8 +160,17 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err)
}
memo, err = s.Store.ComposeMemo(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
}
for _, resourceID := range memoPatch.ResourceIDList {
resourceIDList := make([]int, 0)
for _, resource := range memo.ResourceList {
resourceIDList = append(resourceIDList, resource.ID)
}
addedResourceIDList, removedResourceIDList := getIDListDiff(resourceIDList, memoPatch.ResourceIDList)
for _, resourceID := range addedResourceIDList {
if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
MemoID: memo.ID,
ResourceID: resourceID,
@ -169,19 +178,47 @@ func (s *Server) registerMemoRoutes(g *echo.Group) {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo resource").SetInternal(err)
}
}
for _, resourceID := range removedResourceIDList {
if err := s.Store.DeleteMemoResource(ctx, &api.MemoResourceDelete{
MemoID: &memo.ID,
ResourceID: &resourceID,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo resource").SetInternal(err)
}
}
if s.Profile.IsDev() {
patchMemoRelationList := make([]*api.MemoRelation, 0)
for _, memoRelationUpsert := range memoPatch.RelationList {
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelationMessage{
patchMemoRelationList = append(patchMemoRelationList, &api.MemoRelation{
MemoID: memo.ID,
RelatedMemoID: memoRelationUpsert.RelatedMemoID,
Type: store.MemoRelationType(memoRelationUpsert.Type),
Type: memoRelationUpsert.Type,
})
}
addedMemoRelationList, removedMemoRelationList := getMemoRelationListDiff(memo.RelationList, patchMemoRelationList)
for _, memoRelation := range addedMemoRelationList {
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelationMessage{
MemoID: memo.ID,
RelatedMemoID: memoRelation.RelatedMemoID,
Type: store.MemoRelationType(memoRelation.Type),
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err)
}
}
for _, memoRelation := range removedMemoRelationList {
memoRelationType := store.MemoRelationType(memoRelation.Type)
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelationMessage{
MemoID: &memo.ID,
RelatedMemoID: &memoRelation.RelatedMemoID,
Type: &memoRelationType,
}); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete memo relation").SetInternal(err)
}
}
}
// After patching memo resources and relations, we need to re-compose it to get the latest data.
memo, err = s.Store.ComposeMemo(ctx, memo)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err)
@ -452,3 +489,49 @@ func (s *Server) createMemoCreateActivity(c echo.Context, memo *api.Memo) error
}
return err
}
func getIDListDiff(oldList, newList []int) (addedList, removedList []int) {
oldMap := map[int]bool{}
for _, id := range oldList {
oldMap[id] = true
}
newMap := map[int]bool{}
for _, id := range newList {
newMap[id] = true
}
for id := range oldMap {
if !newMap[id] {
removedList = append(removedList, id)
}
}
for id := range newMap {
if !oldMap[id] {
addedList = append(addedList, id)
}
}
return addedList, removedList
}
func getMemoRelationListDiff(oldList, newList []*api.MemoRelation) (addedList, removedList []*api.MemoRelation) {
oldMap := map[string]bool{}
for _, relation := range oldList {
oldMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
}
newMap := map[string]bool{}
for _, relation := range newList {
newMap[fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)] = true
}
for _, relation := range oldList {
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
if !newMap[key] {
removedList = append(removedList, relation)
}
}
for _, relation := range newList {
key := fmt.Sprintf("%d-%s", relation.RelatedMemoID, relation.Type)
if !oldMap[key] {
addedList = append(addedList, relation)
}
}
return addedList, removedList
}

View File

@ -43,6 +43,8 @@ func (raw *memoRaw) toMemo() *api.Memo {
Content: raw.Content,
Visibility: raw.Visibility,
Pinned: raw.Pinned,
ResourceList: []*api.Resource{},
RelationList: []*api.MemoRelation{},
}
}

View File

@ -18,6 +18,7 @@ func (s *Store) ComposeMemoRelationList(ctx context.Context, memo *api.Memo) err
return err
}
memo.RelationList = []*api.MemoRelation{}
for _, memoRelation := range memoRelationList {
memo.RelationList = append(memo.RelationList, &api.MemoRelation{
MemoID: memoRelation.MemoID,

View File

@ -98,7 +98,7 @@ const AskAIDialog: React.FC<Props> = (props: Props) => {
</div>
) : (
<div className="w-full flex flex-row justify-start items-start pr-8 space-x-2">
<Icon.Bot className="mt-2 flex-shrink-0 mr-1 w-6 h-auto opacity-80" />
<Icon.Bot className="mt-2 shrink-0 mr-1 w-6 h-auto opacity-80" />
<div className="memo-content-wrapper !w-auto flex flex-col justify-start items-start shadow rounded-lg rounded-tl-none px-3 py-2 bg-gray-100 dark:bg-zinc-700">
<div className="memo-content-text">{marked(message.content)}</div>
</div>

View File

@ -32,7 +32,7 @@ const Header = () => {
return (
<div
className={`fixed sm:sticky top-0 left-0 w-full sm:w-56 h-full flex-shrink-0 pointer-events-none sm:pointer-events-auto z-20 ${
className={`fixed sm:sticky top-0 left-0 w-full sm:w-56 h-full shrink-0 pointer-events-none sm:pointer-events-auto z-20 ${
showHeader && "pointer-events-auto"
}`}
>

View File

@ -42,7 +42,7 @@ const HomeSidebar = () => {
return (
<div
className={`fixed md:sticky top-0 left-0 w-full md:w-56 h-full flex-shrink-0 pointer-events-none md:pointer-events-auto z-10 ${
className={`fixed md:sticky top-0 left-0 w-full md:w-56 h-full shrink-0 pointer-events-none md:pointer-events-auto z-10 ${
showHomeSidebar && "pointer-events-auto"
}`}
>

View File

@ -1,9 +1,12 @@
import { getRelativeTimeString } from "@/helpers/datetime";
import { isEqual, uniqWith } from "lodash-es";
import { memo, useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useEditorStore, useFilterStore, useMemoStore, useUserStore } from "@/store/module";
import { getRelativeTimeString } from "@/helpers/datetime";
import { UNKNOWN_ID } from "@/helpers/consts";
import { useMemoCacheStore } from "@/store/zustand";
import Tooltip from "./kit/Tooltip";
import Divider from "./kit/Divider";
import { showCommonDialog } from "./Dialog/CommonDialog";
@ -13,24 +16,34 @@ import MemoResources from "./MemoResources";
import showShareMemo from "./ShareMemoDialog";
import showPreviewImageDialog from "./PreviewImageDialog";
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
import MemoRelationListView from "./MemoRelationListView";
import "@/less/memo.less";
interface Props {
memo: Memo;
readonly?: boolean;
showRelatedMemos?: boolean;
}
const Memo: React.FC<Props> = (props: Props) => {
const { memo, readonly } = props;
const { memo, readonly, showRelatedMemos } = props;
const { t, i18n } = useTranslation();
const editorStore = useEditorStore();
const filterStore = useFilterStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const memoCacheStore = useMemoCacheStore();
const [createdTimeStr, setCreatedTimeStr] = useState<string>(getRelativeTimeString(memo.createdTs));
const [relatedMemoList, setRelatedMemoList] = useState<Memo[]>([]);
const memoContainerRef = useRef<HTMLDivElement>(null);
const isVisitorMode = userStore.isVisitorMode() || readonly;
useEffect(() => {
Promise.all(memo.relationList.map((memoRelation) => memoCacheStore.getOrFetchMemoById(memoRelation.relatedMemoId))).then((memoList) => {
setRelatedMemoList(uniqWith(memoList, isEqual));
});
}, [memo.relationList]);
useEffect(() => {
let intervalFlag: any = -1;
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
@ -178,8 +191,21 @@ const Memo: React.FC<Props> = (props: Props) => {
}
};
const handleMarkMemo = () => {
const relation: MemoRelation = {
memoId: UNKNOWN_ID,
relatedMemoId: memo.id,
type: "REFERENCE",
};
editorStore.setRelationList(uniqWith([...editorStore.state.relationList, relation], isEqual));
};
return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`} ref={memoContainerRef}>
<>
<div
className={`memo-wrapper ${"memos-" + memo.id} ${relatedMemoList.length > 0 && "pinned"} ${memo.pinned ? "pinned" : ""}`}
ref={memoContainerRef}
>
<div className="memo-top-wrapper">
<div className="status-text-container">
<Link className="time-text" to={`/m/${memo.id}`} onClick={handleMemoCreatedTimeClick}>
@ -222,6 +248,10 @@ const Memo: React.FC<Props> = (props: Props) => {
<Icon.Share className="w-4 h-auto mr-2" />
{t("common.share")}
</span>
<span className="btn" onClick={handleMarkMemo}>
<Icon.Link className="w-4 h-auto mr-2" />
Mark
</span>
<Divider />
<span className="btn text-orange-500" onClick={handleArchiveMemoClick}>
<Icon.Archive className="w-4 h-auto mr-2" />
@ -242,7 +272,25 @@ const Memo: React.FC<Props> = (props: Props) => {
onMemoContentDoubleClick={handleMemoContentDoubleClick}
/>
<MemoResources resourceList={memo.resourceList} />
{!showRelatedMemos && <MemoRelationListView relationList={memo.relationList} />}
</div>
{showRelatedMemos && relatedMemoList.length > 0 && (
<>
<p className="font-mono text-sm mt-4 mb-1 pl-4 opacity-60 flex flex-row items-center">
<Icon.Link className="w-4 h-auto mr-1" />
<span>Related memos</span>
</p>
{relatedMemoList.map((relatedMemo) => {
return (
<div key={relatedMemo.id} className="w-full">
<Memo memo={relatedMemo} readonly={readonly} />
</div>
);
})}
</>
)}
</>
);
};

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { useEditorStore } from "@/store/module";
import { useMemoCacheStore } from "@/store/zustand";
import Icon from "../Icon";
interface FormatedMemoRelation extends MemoRelation {
relatedMemo: Memo;
}
const RelationListView = () => {
const editorStore = useEditorStore();
const memoCacheStore = useMemoCacheStore();
const [formatedMemoRelationList, setFormatedMemoRelationList] = useState<FormatedMemoRelation[]>([]);
const relationList = editorStore.state.relationList;
useEffect(() => {
const fetchRelatedMemoList = async () => {
const requests = relationList.map(async (relation) => {
const relatedMemo = await memoCacheStore.getOrFetchMemoById(relation.relatedMemoId);
return {
...relation,
relatedMemo,
};
});
const list = await Promise.all(requests);
setFormatedMemoRelationList(list);
};
fetchRelatedMemoList();
}, [relationList]);
const handleDeleteRelation = async (memoRelation: FormatedMemoRelation) => {
const newRelationList = relationList.filter((relation) => relation.relatedMemoId !== memoRelation.relatedMemoId);
editorStore.setRelationList(newRelationList);
};
return (
<>
{formatedMemoRelationList.length > 0 && (
<div className="w-full flex flex-row gap-2 mt-2 flex-wrap">
{formatedMemoRelationList.map((memoRelation) => {
return (
<div
key={memoRelation.relatedMemoId}
className="w-auto max-w-[50%] overflow-hidden flex flex-row justify-start items-center bg-gray-100 hover:bg-gray-200 rounded text-sm p-1 px-2 text-gray-500 cursor-pointer"
>
<Icon.Link className="w-4 h-auto shrink-0" />
<span className="mx-1 max-w-full text-ellipsis font-mono whitespace-nowrap overflow-hidden">
{memoRelation.relatedMemo.content}
</span>
<Icon.X className="w-4 h-auto hover:opacity-80 shrink-0" onClick={() => handleDeleteRelation(memoRelation)} />
</div>
);
})}
</div>
)}
</>
);
};
export default RelationListView;

View File

@ -1,5 +1,4 @@
import { useEditorStore } from "@/store/module";
import { deleteMemoResource } from "@/helpers/api";
import Icon from "../Icon";
import ResourceIcon from "../ResourceIcon";
@ -9,21 +8,21 @@ const ResourceListView = () => {
const handleDeleteResource = async (resourceId: ResourceId) => {
editorStore.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId));
if (editorState.editMemoId) {
await deleteMemoResource(editorState.editMemoId, resourceId);
}
};
return (
<>
{editorState.resourceList && editorState.resourceList.length > 0 && (
<div className="resource-list-wrapper">
<div className="w-full flex flex-row justify-start flex-wrap gap-2 mt-2">
{editorState.resourceList.map((resource) => {
return (
<div key={resource.id} className="resource-container">
<ResourceIcon resourceType="resource.type" className="icon-img" />
<span className="name-text">{resource.filename}</span>
<Icon.X className="close-icon" onClick={() => handleDeleteResource(resource.id)} />
<div
key={resource.id}
className="max-w-full flex flex-row justify-start items-center flex-nowrap bg-gray-100 px-2 py-1 rounded cursor-pointer text-gray-500 hover:bg-gray-200 dark:opacity-60"
>
<ResourceIcon resourceType={resource.type} className="w-4 h-auto mr-1" />
<span className="text-sm max-w-xs truncate font-mono">{resource.filename}</span>
<Icon.X className="w-4 h-auto ml-1 hover:opacity-80" onClick={() => handleDeleteResource(resource.id)} />
</div>
);
})}
@ -32,4 +31,5 @@ const ResourceListView = () => {
</>
);
};
export default ResourceListView;

View File

@ -13,8 +13,9 @@ import Editor, { EditorRefActions } from "./Editor";
import TagSelector from "./ActionButton/TagSelector";
import ResourceSelector from "./ActionButton/ResourceSelector";
import MemoVisibilitySelector from "./ActionButton/MemoVisibilitySelector";
import "@/less/memo-editor.less";
import ResourceListView from "./ResourceListView";
import RelationListView from "./RelationListView";
import "@/less/memo-editor.less";
const listItemSymbolList = ["- [ ] ", "- [x] ", "- [X] ", "* ", "- "];
const emptyOlReg = /^(\d+)\. $/;
@ -73,6 +74,7 @@ const MemoEditor = () => {
handleEditorFocus();
editorStore.setMemoVisibility(memo.visibility);
editorStore.setResourceList(memo.resourceList);
editorStore.setRelationList(memo.relationList);
editorRef.current?.setContent(memo.content ?? "");
}
});
@ -232,6 +234,7 @@ const MemoEditor = () => {
content,
visibility: editorState.memoVisibility,
resourceIdList: editorState.resourceList.map((resource) => resource.id),
relationList: editorState.relationList,
});
}
editorStore.clearEditMemo();
@ -240,7 +243,7 @@ const MemoEditor = () => {
content,
visibility: editorState.memoVisibility,
resourceIdList: editorState.resourceList.map((resource) => resource.id),
relationList: [],
relationList: editorState.relationList,
});
filterStore.clearFilter();
}
@ -268,7 +271,8 @@ const MemoEditor = () => {
fullscreen: false,
};
});
editorStore.clearResourceList();
editorStore.setResourceList([]);
editorStore.setRelationList([]);
setEditorContentCache("");
editorRef.current?.setContent("");
clearContentQueryParam();
@ -277,7 +281,8 @@ const MemoEditor = () => {
const handleCancelEdit = () => {
if (editorState.editMemoId) {
editorStore.clearEditMemo();
editorStore.clearResourceList();
editorStore.setResourceList([]);
editorStore.setRelationList([]);
editorRef.current?.setContent("");
setEditorContentCache("");
}
@ -381,6 +386,7 @@ const MemoEditor = () => {
</div>
</div>
<ResourceListView />
<RelationListView />
<div className="editor-footer-container">
<MemoVisibilitySelector />
<div className="buttons-container">

View File

@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { useMemoCacheStore } from "@/store/zustand";
import Icon from "./Icon";
interface Props {
relationList: MemoRelation[];
}
const MemoRelationListView = (props: Props) => {
const memoCacheStore = useMemoCacheStore();
const [relatedMemoList, setRelatedMemoList] = useState<Memo[]>([]);
const relationList = props.relationList;
useEffect(() => {
const fetchRelatedMemoList = async () => {
const requests = relationList.map((relation) => memoCacheStore.getOrFetchMemoById(relation.relatedMemoId));
const memoList = await Promise.all(requests);
setRelatedMemoList(memoList);
};
fetchRelatedMemoList();
}, [relationList]);
return (
<>
{relatedMemoList.length > 0 && (
<div className="w-full max-w-full overflow-hidden grid grid-cols-1 gap-1 mt-2">
{relatedMemoList.map((memo) => {
return (
<div
key={memo.id}
className="w-auto flex flex-row justify-start items-center hover:bg-gray-100 dark:hover:bg-zinc-800 rounded text-sm p-1 text-gray-500 dark:text-gray-400 cursor-pointer"
>
<div className="w-5 h-5 flex justify-center items-center shrink-0 bg-gray-100 dark:bg-zinc-800 rounded-full">
<Icon.Link className="w-3 h-auto" />
</div>
<span className="mx-1 w-auto truncate">{memo.content}</span>
</div>
);
})}
</div>
)}
</>
);
};
export default MemoRelationListView;

View File

@ -10,7 +10,7 @@ function Root() {
</div>
<div className="w-full max-w-6xl mx-auto flex flex-row justify-center items-start">
<Header />
<main className="w-auto flex-grow flex flex-col justify-start items-start">
<main className="w-auto max-w-full flex-grow shrink flex flex-col justify-start items-start">
<Outlet />
</main>
</div>

View File

@ -11,7 +11,7 @@
@apply w-full mt-2 py-1 flex sm:flex-row flex-col justify-start items-start;
> .normal-text {
@apply block flex-shrink-0 w-12 mr-3 sm:text-right text-left text-sm leading-8;
@apply block shrink-0 w-12 mr-3 sm:text-right text-left text-sm leading-8;
color: gray;
}

View File

@ -1,65 +0,0 @@
.page-wrapper.memo-detail {
@apply relative top-0 w-full h-full overflow-y-auto overflow-x-hidden bg-zinc-100 dark:bg-zinc-800;
> .page-container {
@apply relative w-full min-h-full mx-auto flex flex-col justify-start items-center pb-8;
> .page-header {
@apply sticky top-0 z-10 max-w-2xl w-full min-h-full flex flex-row justify-between items-center px-4 pt-6 mb-2 bg-zinc-100 dark:bg-zinc-800;
> .title-container {
@apply flex flex-row justify-start items-center;
> .logo-img {
@apply h-12 sm:h-14 w-auto mr-2;
}
> .logo-text {
@apply text-4xl tracking-wide text-black dark:text-white;
}
> .title-text {
@apply text-xl sm:text-3xl font-mono text-gray-700;
}
}
> .action-button-container {
> .btn {
@apply block text-gray-600 dark:text-gray-300 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline;
> .icon {
@apply text-lg;
}
}
}
}
> .memos-wrapper {
@apply relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4;
> .memo-container {
@apply flex flex-col justify-start items-start w-full p-4 mt-2 bg-white dark:bg-zinc-700 rounded-lg border border-white dark:border-zinc-800 hover:border-gray-200 dark:hover:border-zinc-700;
> .memo-header {
@apply mb-2 w-full flex flex-row justify-between items-center;
> .status-container {
@apply flex flex-row justify-start items-center text-sm text-gray-400;
> .name-text {
@apply ml-2 hover:text-green-600 hover:underline;
}
}
}
> .memo-content {
@apply cursor-default;
> * {
@apply cursor-default;
}
}
}
}
}
}

View File

@ -50,26 +50,6 @@
}
}
> .resource-list-wrapper {
@apply w-full flex flex-row justify-start flex-wrap;
> .resource-container {
@apply max-w-full mt-1 mr-1 flex flex-row justify-start items-center flex-nowrap bg-gray-100 px-2 py-1 rounded cursor-pointer hover:bg-gray-200;
> .icon-img {
@apply w-4 h-auto mr-1 text-gray-500;
}
> .name-text {
@apply text-gray-500 text-sm max-w-xs truncate font-mono;
}
> .close-icon {
@apply w-4 h-auto ml-1 text-gray-500 hover:text-gray-800;
}
}
}
> .editor-footer-container {
@apply w-full flex flex-row justify-between items-center border-t border-t-gray-100 dark:border-t-zinc-500 py-3 mt-2;

View File

@ -41,7 +41,7 @@
}
> .btns-container {
@apply flex flex-row justify-end items-center relative flex-shrink-0;
@apply flex flex-row justify-end items-center relative shrink-0;
> .more-action-btns-wrapper {
@apply hidden flex-col justify-start items-center absolute top-2 -right-4 flex-nowrap hover:flex p-3;

View File

@ -140,7 +140,7 @@ const Auth = () => {
<div className={`flex flex-col justify-start items-start w-full ${actionBtnLoadingState.isLoading && "opacity-80"}`}>
<div className="flex flex-col justify-start items-start relative w-full text-base mt-2 py-2">
<span
className={`absolute top-3 left-3 px-1 leading-10 flex-shrink-0 text-base cursor-text text-gray-400 transition-all select-none pointer-events-none ${
className={`absolute top-3 left-3 px-1 leading-10 shrink-0 text-base cursor-text text-gray-400 transition-all select-none pointer-events-none ${
username ? "!text-sm !top-0 !z-10 !leading-4 bg-white dark:bg-zinc-800 rounded" : ""
}`}
>
@ -156,7 +156,7 @@ const Auth = () => {
</div>
<div className="flex flex-col justify-start items-start relative w-full text-base mt-2 py-2">
<span
className={`absolute top-3 left-3 px-1 leading-10 flex-shrink-0 text-base cursor-text text-gray-400 transition-all select-none pointer-events-none ${
className={`absolute top-3 left-3 px-1 leading-10 shrink-0 text-base cursor-text text-gray-400 transition-all select-none pointer-events-none ${
password ? "!text-sm !top-0 !z-10 !leading-4 bg-white dark:bg-zinc-800 rounded" : ""
}`}
>

View File

@ -32,7 +32,7 @@ function Home() {
return (
<div className="w-full flex flex-row justify-start items-start">
<div className="flex-grow w-auto max-w-2xl px-4 sm:px-2 sm:pt-4">
<div className="flex-grow shrink w-auto max-w-2xl px-4 sm:px-2 sm:pt-4">
<MobileHeader />
<div className="w-full h-auto flex flex-col justify-start items-start bg-zinc-100 dark:bg-zinc-800 rounded-lg">
{!userStore.isVisitorMode() && <MemoEditor />}

View File

@ -5,10 +5,7 @@ import { Link, useLocation, useParams } from "react-router-dom";
import { UNKNOWN_ID } from "@/helpers/consts";
import { useGlobalStore, useMemoStore, useUserStore } from "@/store/module";
import useLoading from "@/hooks/useLoading";
import MemoContent from "@/components/MemoContent";
import MemoResources from "@/components/MemoResources";
import { getDateTimeString } from "@/helpers/datetime";
import "@/less/memo-detail.less";
import Memo from "@/components/Memo";
interface State {
memo: Memo;
@ -49,23 +46,29 @@ const MemoDetail = () => {
}, [location]);
return (
<section className="page-wrapper memo-detail">
<div className="page-container">
<div className="page-header">
<div className="title-container">
<section className="relative top-0 w-full h-full overflow-y-auto overflow-x-hidden bg-zinc-100 dark:bg-zinc-800">
<div className="relative w-full min-h-full mx-auto flex flex-col justify-start items-center pb-8">
<div className="sticky top-0 z-10 max-w-2xl w-full min-h-full flex flex-row justify-between items-center px-4 py-2 mt-2 bg-zinc-100 dark:bg-zinc-800">
<div className="flex flex-row justify-start items-center">
<img className="h-10 w-auto rounded-lg mr-2" src={customizedProfile.logoUrl} alt="" />
<p className="logo-text">{customizedProfile.name}</p>
<p className="text-4xl tracking-wide text-black dark:text-white">{customizedProfile.name}</p>
</div>
<div className="action-button-container">
{!loadingState.isLoading && (
<>
{user ? (
<Link to="/" className="btn">
<span className="icon">🏠</span> {t("router.back-to-home")}
<Link
to="/"
className="block text-gray-600 dark:text-gray-300 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline"
>
<span className="text-lg">🏠</span> {t("router.back-to-home")}
</Link>
) : (
<Link to="/auth" className="btn">
<span className="icon">👉</span> {t("common.sign-in")}
<Link
to="/auth"
className="block text-gray-600 dark:text-gray-300 font-mono text-base py-1 border px-3 leading-8 rounded-xl hover:opacity-80 hover:underline"
>
<span className="text-lg">👉</span> {t("common.sign-in")}
</Link>
)}
</>
@ -73,19 +76,8 @@ const MemoDetail = () => {
</div>
</div>
{!loadingState.isLoading && (
<main className="memos-wrapper">
<div className="memo-container">
<div className="memo-header">
<div className="status-container">
<span className="time-text">{getDateTimeString(state.memo.createdTs)}</span>
<a className="name-text" href={`/u/${state.memo.creatorId}`}>
@{state.memo.creatorName}
</a>
</div>
</div>
<MemoContent className="memo-content" content={state.memo.content} showFull={true} onMemoContentClick={() => undefined} />
<MemoResources resourceList={state.memo.resourceList} />
</div>
<main className="relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4">
<Memo memo={state.memo} readonly showRelatedMemos />
</main>
)}
</div>

View File

@ -1,5 +1,5 @@
import store, { useAppSelector } from "..";
import { setEditMemoId, setMemoVisibility, setResourceList } from "../reducer/editor";
import { setEditMemoId, setMemoVisibility, setRelationList, setResourceList } from "../reducer/editor";
export const useEditorStore = () => {
const state = useAppSelector((state) => state.editor);
@ -21,8 +21,8 @@ export const useEditorStore = () => {
setResourceList: (resourceList: Resource[]) => {
store.dispatch(setResourceList(resourceList));
},
clearResourceList: () => {
store.dispatch(setResourceList([]));
setRelationList: (relationList: MemoRelation[]) => {
store.dispatch(setRelationList(relationList));
},
};
};

View File

@ -4,8 +4,9 @@ import { DEFAULT_MEMO_LIMIT } from "@/helpers/consts";
import { useUserStore } from "./";
import store, { useAppSelector } from "../";
import { createMemo, deleteMemo, patchMemo, setIsFetching, upsertMemos } from "../reducer/memo";
import { useMemoCacheStore } from "../zustand/memo";
const convertResponseModelMemo = (memo: Memo): Memo => {
export const convertResponseModelMemo = (memo: Memo): Memo => {
return {
...memo,
createdTs: memo.createdTs * 1000,
@ -16,6 +17,7 @@ const convertResponseModelMemo = (memo: Memo): Memo => {
export const useMemoStore = () => {
const state = useAppSelector((state) => state.memo);
const userStore = useUserStore();
const memoCacheStore = useMemoCacheStore();
const fetchMemoById = async (memoId: MemoId) => {
const { data } = (await api.getMemoById(memoId)).data;
@ -44,6 +46,10 @@ export const useMemoStore = () => {
store.dispatch(upsertMemos(fetchedMemos));
store.dispatch(setIsFetching(false));
for (const m of fetchedMemos) {
memoCacheStore.setMemoCache(m);
}
return fetchedMemos;
},
fetchAllMemos: async (limit = DEFAULT_MEMO_LIMIT, offset?: number) => {
@ -54,8 +60,13 @@ export const useMemoStore = () => {
};
const { data } = (await api.getAllMemos(memoFind)).data;
const memos = data.map((m) => convertResponseModelMemo(m));
return memos;
const fetchedMemos = data.map((m) => convertResponseModelMemo(m));
for (const m of fetchedMemos) {
memoCacheStore.setMemoCache(m);
}
return fetchedMemos;
},
fetchArchivedMemos: async () => {
const memoFind: MemoFind = {
@ -88,12 +99,14 @@ export const useMemoStore = () => {
const { data } = (await api.createMemo(memoCreate)).data;
const memo = convertResponseModelMemo(data);
store.dispatch(createMemo(memo));
memoCacheStore.setMemoCache(memo);
return memo;
},
patchMemo: async (memoPatch: MemoPatch): Promise<Memo> => {
const { data } = (await api.patchMemo(memoPatch)).data;
const memo = convertResponseModelMemo(data);
store.dispatch(patchMemo(omit(memo, "pinned")));
memoCacheStore.setMemoCache(memo);
return memo;
},
pinMemo: async (memoId: MemoId) => {
@ -117,6 +130,7 @@ export const useMemoStore = () => {
deleteMemoById: async (memoId: MemoId) => {
await api.deleteMemo(memoId);
store.dispatch(deleteMemo(memoId));
memoCacheStore.deleteMemoCache(memoId);
},
};
};

View File

@ -3,6 +3,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
memoVisibility: Visibility;
resourceList: Resource[];
relationList: MemoRelation[];
editMemoId?: MemoId;
}
@ -11,6 +12,7 @@ const editorSlice = createSlice({
initialState: {
memoVisibility: "PRIVATE",
resourceList: [],
relationList: [],
} as State,
reducers: {
setEditMemoId: (state, action: PayloadAction<Option<MemoId>>) => {
@ -31,9 +33,15 @@ const editorSlice = createSlice({
resourceList: action.payload,
};
},
setRelationList: (state, action: PayloadAction<MemoRelation[]>) => {
return {
...state,
relationList: action.payload,
};
},
},
});
export const { setEditMemoId, setMemoVisibility, setResourceList } = editorSlice.actions;
export const { setEditMemoId, setMemoVisibility, setResourceList, setRelationList } = editorSlice.actions;
export default editorSlice.reducer;

View File

@ -0,0 +1,2 @@
export { useMemoCacheStore } from "./memo";
export { useMessageStore } from "./message";

View File

@ -0,0 +1,41 @@
import { create } from "zustand";
import { combine } from "zustand/middleware";
import * as api from "@/helpers/api";
import { convertResponseModelMemo } from "../module";
export const useMemoCacheStore = create(
combine({ memoById: new Map<MemoId, Memo>() }, (set, get) => ({
getState: () => get(),
getOrFetchMemoById: async (memoId: MemoId) => {
const memo = get().memoById.get(memoId);
if (memo) {
return memo;
}
const { data } = (await api.getMemoById(memoId)).data;
const formatedMemo = convertResponseModelMemo(data);
set((state) => {
state.memoById.set(memoId, formatedMemo);
return state;
});
return formatedMemo;
},
getMemoById: (memoId: MemoId) => {
return get().memoById.get(memoId);
},
setMemoCache: (memo: Memo) => {
set((state) => {
state.memoById.set(memo.id, memo);
return state;
});
},
deleteMemoCache: (memoId: MemoId) => {
set((state) => {
state.memoById.delete(memoId);
return state;
});
},
}))
);