mirror of
https://github.com/usememos/memos.git
synced 2024-11-24 06:35:24 +03:00
feat: memo relation part1 (#1677)
* feat: memo relation part1 * chore: update
This commit is contained in:
parent
ca5859296a
commit
a07d5d38d6
@ -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
|
||||
}
|
||||
|
@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
}`}
|
||||
>
|
||||
|
@ -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"
|
||||
}`}
|
||||
>
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
60
web/src/components/MemoEditor/RelationListView.tsx
Normal file
60
web/src/components/MemoEditor/RelationListView.tsx
Normal 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;
|
@ -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;
|
||||
|
@ -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">
|
||||
|
46
web/src/components/MemoRelationListView.tsx
Normal file
46
web/src/components/MemoRelationListView.tsx
Normal 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;
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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" : ""
|
||||
}`}
|
||||
>
|
||||
|
@ -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 />}
|
||||
|
@ -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>
|
||||
|
@ -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));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
|
2
web/src/store/zustand/index.ts
Normal file
2
web/src/store/zustand/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { useMemoCacheStore } from "./memo";
|
||||
export { useMessageStore } from "./message";
|
41
web/src/store/zustand/memo.ts
Normal file
41
web/src/store/zustand/memo.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
Loading…
Reference in New Issue
Block a user