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 { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to patch memo").SetInternal(err) 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{ if _, err := s.Store.UpsertMemoResource(ctx, &api.MemoResourceUpsert{
MemoID: memo.ID, MemoID: memo.ID,
ResourceID: resourceID, 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) 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() { if s.Profile.IsDev() {
patchMemoRelationList := make([]*api.MemoRelation, 0)
for _, memoRelationUpsert := range memoPatch.RelationList { for _, memoRelationUpsert := range memoPatch.RelationList {
if _, err := s.Store.UpsertMemoRelation(ctx, &store.MemoRelationMessage{ patchMemoRelationList = append(patchMemoRelationList, &api.MemoRelation{
MemoID: memo.ID, MemoID: memo.ID,
RelatedMemoID: memoRelationUpsert.RelatedMemoID, 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 { }); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert memo relation").SetInternal(err) 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) memo, err = s.Store.ComposeMemo(ctx, memo)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compose memo").SetInternal(err) 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 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

@ -40,9 +40,11 @@ func (raw *memoRaw) toMemo() *api.Memo {
UpdatedTs: raw.UpdatedTs, UpdatedTs: raw.UpdatedTs,
// Domain specific fields // Domain specific fields
Content: raw.Content, Content: raw.Content,
Visibility: raw.Visibility, Visibility: raw.Visibility,
Pinned: raw.Pinned, 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 return err
} }
memo.RelationList = []*api.MemoRelation{}
for _, memoRelation := range memoRelationList { for _, memoRelation := range memoRelationList {
memo.RelationList = append(memo.RelationList, &api.MemoRelation{ memo.RelationList = append(memo.RelationList, &api.MemoRelation{
MemoID: memoRelation.MemoID, MemoID: memoRelation.MemoID,

View File

@ -98,7 +98,7 @@ const AskAIDialog: React.FC<Props> = (props: Props) => {
</div> </div>
) : ( ) : (
<div className="w-full flex flex-row justify-start items-start pr-8 space-x-2"> <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-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 className="memo-content-text">{marked(message.content)}</div>
</div> </div>

View File

@ -32,7 +32,7 @@ const Header = () => {
return ( return (
<div <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" showHeader && "pointer-events-auto"
}`} }`}
> >

View File

@ -42,7 +42,7 @@ const HomeSidebar = () => {
return ( return (
<div <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" 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 { memo, useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEditorStore, useFilterStore, useMemoStore, useUserStore } from "@/store/module"; 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 Tooltip from "./kit/Tooltip";
import Divider from "./kit/Divider"; import Divider from "./kit/Divider";
import { showCommonDialog } from "./Dialog/CommonDialog"; import { showCommonDialog } from "./Dialog/CommonDialog";
@ -13,24 +16,34 @@ import MemoResources from "./MemoResources";
import showShareMemo from "./ShareMemoDialog"; import showShareMemo from "./ShareMemoDialog";
import showPreviewImageDialog from "./PreviewImageDialog"; import showPreviewImageDialog from "./PreviewImageDialog";
import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog"; import showChangeMemoCreatedTsDialog from "./ChangeMemoCreatedTsDialog";
import MemoRelationListView from "./MemoRelationListView";
import "@/less/memo.less"; import "@/less/memo.less";
interface Props { interface Props {
memo: Memo; memo: Memo;
readonly?: boolean; readonly?: boolean;
showRelatedMemos?: boolean;
} }
const Memo: React.FC<Props> = (props: Props) => { const Memo: React.FC<Props> = (props: Props) => {
const { memo, readonly } = props; const { memo, readonly, showRelatedMemos } = props;
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const editorStore = useEditorStore(); const editorStore = useEditorStore();
const filterStore = useFilterStore(); const filterStore = useFilterStore();
const userStore = useUserStore(); const userStore = useUserStore();
const memoStore = useMemoStore(); const memoStore = useMemoStore();
const memoCacheStore = useMemoCacheStore();
const [createdTimeStr, setCreatedTimeStr] = useState<string>(getRelativeTimeString(memo.createdTs)); const [createdTimeStr, setCreatedTimeStr] = useState<string>(getRelativeTimeString(memo.createdTs));
const [relatedMemoList, setRelatedMemoList] = useState<Memo[]>([]);
const memoContainerRef = useRef<HTMLDivElement>(null); const memoContainerRef = useRef<HTMLDivElement>(null);
const isVisitorMode = userStore.isVisitorMode() || readonly; 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(() => { useEffect(() => {
let intervalFlag: any = -1; let intervalFlag: any = -1;
if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) { if (Date.now() - memo.createdTs < 1000 * 60 * 60 * 24) {
@ -178,71 +191,106 @@ 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 ( return (
<div className={`memo-wrapper ${"memos-" + memo.id} ${memo.pinned ? "pinned" : ""}`} ref={memoContainerRef}> <>
<div className="memo-top-wrapper"> <div
<div className="status-text-container"> className={`memo-wrapper ${"memos-" + memo.id} ${relatedMemoList.length > 0 && "pinned"} ${memo.pinned ? "pinned" : ""}`}
<Link className="time-text" to={`/m/${memo.id}`} onClick={handleMemoCreatedTimeClick}> ref={memoContainerRef}
{createdTimeStr} >
</Link> <div className="memo-top-wrapper">
{isVisitorMode && ( <div className="status-text-container">
<Link className="name-text" to={`/u/${memo.creatorId}`}> <Link className="time-text" to={`/m/${memo.id}`} onClick={handleMemoCreatedTimeClick}>
@{memo.creatorName} {createdTimeStr}
</Link> </Link>
)} {isVisitorMode && (
</div> <Link className="name-text" to={`/u/${memo.creatorId}`}>
{!isVisitorMode && ( @{memo.creatorName}
<div className="btns-container space-x-2"> </Link>
{memo.visibility !== "PRIVATE" && (
<Tooltip title={t(`memo.visibility.${memo.visibility.toLowerCase()}`)} side="top">
<div onClick={() => handleMemoVisibilityClick(memo.visibility)}>
{memo.visibility === "PUBLIC" ? (
<Icon.Globe2 className="w-4 h-auto cursor-pointer rounded text-green-600" />
) : (
<Icon.Users className="w-4 h-auto cursor-pointer rounded text-gray-500 dark:text-gray-400" />
)}
</div>
</Tooltip>
)} )}
{memo.pinned && <Icon.Bookmark className="w-4 h-auto rounded text-green-600" />} </div>
<span className="btn more-action-btn"> {!isVisitorMode && (
<Icon.MoreHorizontal className="icon-img" /> <div className="btns-container space-x-2">
</span> {memo.visibility !== "PRIVATE" && (
<div className="more-action-btns-wrapper"> <Tooltip title={t(`memo.visibility.${memo.visibility.toLowerCase()}`)} side="top">
<div className="more-action-btns-container min-w-[6em]"> <div onClick={() => handleMemoVisibilityClick(memo.visibility)}>
<span className="btn" onClick={handleTogglePinMemoBtnClick}> {memo.visibility === "PUBLIC" ? (
{memo.pinned ? <Icon.BookmarkMinus className="w-4 h-auto mr-2" /> : <Icon.BookmarkPlus className="w-4 h-auto mr-2" />} <Icon.Globe2 className="w-4 h-auto cursor-pointer rounded text-green-600" />
{memo.pinned ? t("common.unpin") : t("common.pin")} ) : (
</span> <Icon.Users className="w-4 h-auto cursor-pointer rounded text-gray-500 dark:text-gray-400" />
<span className="btn" onClick={handleEditMemoClick}> )}
<Icon.Edit3 className="w-4 h-auto mr-2" /> </div>
{t("common.edit")} </Tooltip>
</span> )}
<span className="btn" onClick={handleGenerateMemoImageBtnClick}> {memo.pinned && <Icon.Bookmark className="w-4 h-auto rounded text-green-600" />}
<Icon.Share className="w-4 h-auto mr-2" /> <span className="btn more-action-btn">
{t("common.share")} <Icon.MoreHorizontal className="icon-img" />
</span> </span>
<Divider /> <div className="more-action-btns-wrapper">
<span className="btn text-orange-500" onClick={handleArchiveMemoClick}> <div className="more-action-btns-container min-w-[6em]">
<Icon.Archive className="w-4 h-auto mr-2" /> <span className="btn" onClick={handleTogglePinMemoBtnClick}>
{t("common.archive")} {memo.pinned ? <Icon.BookmarkMinus className="w-4 h-auto mr-2" /> : <Icon.BookmarkPlus className="w-4 h-auto mr-2" />}
</span> {memo.pinned ? t("common.unpin") : t("common.pin")}
<span className="btn text-red-600" onClick={handleDeleteMemoClick}> </span>
<Icon.Trash className="w-4 h-auto mr-2" /> <span className="btn" onClick={handleEditMemoClick}>
{t("common.delete")} <Icon.Edit3 className="w-4 h-auto mr-2" />
</span> {t("common.edit")}
</span>
<span className="btn" onClick={handleGenerateMemoImageBtnClick}>
<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" />
{t("common.archive")}
</span>
<span className="btn text-red-600" onClick={handleDeleteMemoClick}>
<Icon.Trash className="w-4 h-auto mr-2" />
{t("common.delete")}
</span>
</div>
</div> </div>
</div> </div>
</div> )}
)} </div>
<MemoContent
content={memo.content}
onMemoContentClick={handleMemoContentClick}
onMemoContentDoubleClick={handleMemoContentDoubleClick}
/>
<MemoResources resourceList={memo.resourceList} />
{!showRelatedMemos && <MemoRelationListView relationList={memo.relationList} />}
</div> </div>
<MemoContent
content={memo.content} {showRelatedMemos && relatedMemoList.length > 0 && (
onMemoContentClick={handleMemoContentClick} <>
onMemoContentDoubleClick={handleMemoContentDoubleClick} <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" />
<MemoResources resourceList={memo.resourceList} /> <span>Related memos</span>
</div> </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 { useEditorStore } from "@/store/module";
import { deleteMemoResource } from "@/helpers/api";
import Icon from "../Icon"; import Icon from "../Icon";
import ResourceIcon from "../ResourceIcon"; import ResourceIcon from "../ResourceIcon";
@ -9,21 +8,21 @@ const ResourceListView = () => {
const handleDeleteResource = async (resourceId: ResourceId) => { const handleDeleteResource = async (resourceId: ResourceId) => {
editorStore.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId)); editorStore.setResourceList(editorState.resourceList.filter((resource) => resource.id !== resourceId));
if (editorState.editMemoId) {
await deleteMemoResource(editorState.editMemoId, resourceId);
}
}; };
return ( return (
<> <>
{editorState.resourceList && editorState.resourceList.length > 0 && ( {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) => { {editorState.resourceList.map((resource) => {
return ( return (
<div key={resource.id} className="resource-container"> <div
<ResourceIcon resourceType="resource.type" className="icon-img" /> key={resource.id}
<span className="name-text">{resource.filename}</span> 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"
<Icon.X className="close-icon" onClick={() => handleDeleteResource(resource.id)} /> >
<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> </div>
); );
})} })}
@ -32,4 +31,5 @@ const ResourceListView = () => {
</> </>
); );
}; };
export default ResourceListView; export default ResourceListView;

View File

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

View File

@ -11,7 +11,7 @@
@apply w-full mt-2 py-1 flex sm:flex-row flex-col justify-start items-start; @apply w-full mt-2 py-1 flex sm:flex-row flex-col justify-start items-start;
> .normal-text { > .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; 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 { > .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; @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 { > .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 { > .more-action-btns-wrapper {
@apply hidden flex-col justify-start items-center absolute top-2 -right-4 flex-nowrap hover:flex p-3; @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 w-full ${actionBtnLoadingState.isLoading && "opacity-80"}`}>
<div className="flex flex-col justify-start items-start relative w-full text-base mt-2 py-2"> <div className="flex flex-col justify-start items-start relative w-full text-base mt-2 py-2">
<span <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" : "" username ? "!text-sm !top-0 !z-10 !leading-4 bg-white dark:bg-zinc-800 rounded" : ""
}`} }`}
> >
@ -156,7 +156,7 @@ const Auth = () => {
</div> </div>
<div className="flex flex-col justify-start items-start relative w-full text-base mt-2 py-2"> <div className="flex flex-col justify-start items-start relative w-full text-base mt-2 py-2">
<span <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" : "" 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 ( return (
<div className="w-full flex flex-row justify-start items-start"> <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 /> <MobileHeader />
<div className="w-full h-auto flex flex-col justify-start items-start bg-zinc-100 dark:bg-zinc-800 rounded-lg"> <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 />} {!userStore.isVisitorMode() && <MemoEditor />}

View File

@ -5,10 +5,7 @@ import { Link, useLocation, useParams } from "react-router-dom";
import { UNKNOWN_ID } from "@/helpers/consts"; import { UNKNOWN_ID } from "@/helpers/consts";
import { useGlobalStore, useMemoStore, useUserStore } from "@/store/module"; import { useGlobalStore, useMemoStore, useUserStore } from "@/store/module";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
import MemoContent from "@/components/MemoContent"; import Memo from "@/components/Memo";
import MemoResources from "@/components/MemoResources";
import { getDateTimeString } from "@/helpers/datetime";
import "@/less/memo-detail.less";
interface State { interface State {
memo: Memo; memo: Memo;
@ -49,23 +46,29 @@ const MemoDetail = () => {
}, [location]); }, [location]);
return ( return (
<section className="page-wrapper memo-detail"> <section className="relative top-0 w-full h-full overflow-y-auto overflow-x-hidden bg-zinc-100 dark:bg-zinc-800">
<div className="page-container"> <div className="relative w-full min-h-full mx-auto flex flex-col justify-start items-center pb-8">
<div className="page-header"> <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="title-container"> <div className="flex flex-row justify-start items-center">
<img className="h-10 w-auto rounded-lg mr-2" src={customizedProfile.logoUrl} alt="" /> <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>
<div className="action-button-container"> <div className="action-button-container">
{!loadingState.isLoading && ( {!loadingState.isLoading && (
<> <>
{user ? ( {user ? (
<Link to="/" className="btn"> <Link
<span className="icon">🏠</span> {t("router.back-to-home")} 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>
) : ( ) : (
<Link to="/auth" className="btn"> <Link
<span className="icon">👉</span> {t("common.sign-in")} 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> </Link>
)} )}
</> </>
@ -73,19 +76,8 @@ const MemoDetail = () => {
</div> </div>
</div> </div>
{!loadingState.isLoading && ( {!loadingState.isLoading && (
<main className="memos-wrapper"> <main className="relative flex-grow max-w-2xl w-full min-h-full flex flex-col justify-start items-start px-4">
<div className="memo-container"> <Memo memo={state.memo} readonly showRelatedMemos />
<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> </main>
)} )}
</div> </div>

View File

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

View File

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

View File

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