From be1b758d3073e6d935fae3ec4cfa87c826b471e2 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 31 Dec 2025 09:27:37 +0800 Subject: [PATCH] refactor: simplify memo-metadata components --- web/src/components/LeafletMap.tsx | 5 +- .../components/AttachmentItemCard.tsx | 116 ++++++++++++++++++ .../components/AttachmentListV2.tsx | 81 ++++++++++++ .../MemoEditor/components/EditorMetadata.tsx | 23 ++-- .../components/LocationDisplayV2.tsx | 48 ++++++++ .../components/RelationItemCard.tsx | 64 ++++++++++ .../MemoEditor/components/RelationListV2.tsx | 62 ++++++++++ .../components/MemoEditor/components/index.ts | 5 + web/src/components/MemoEditor/index.tsx | 2 +- .../MemoView/components/MemoBody.tsx | 6 +- .../memo-metadata/AttachmentCard.tsx | 92 +++----------- .../memo-metadata/AttachmentList.tsx | 80 +----------- .../memo-metadata/LocationDisplay.tsx | 34 ++--- .../components/memo-metadata/RelationCard.tsx | 46 +------ .../components/memo-metadata/RelationList.tsx | 70 ++--------- .../components/memo-metadata/SortableItem.tsx | 25 ---- web/src/components/memo-metadata/index.ts | 3 +- web/src/utils/format.ts | 75 +++++++++++ 18 files changed, 506 insertions(+), 331 deletions(-) create mode 100644 web/src/components/MemoEditor/components/AttachmentItemCard.tsx create mode 100644 web/src/components/MemoEditor/components/AttachmentListV2.tsx create mode 100644 web/src/components/MemoEditor/components/LocationDisplayV2.tsx create mode 100644 web/src/components/MemoEditor/components/RelationItemCard.tsx create mode 100644 web/src/components/MemoEditor/components/RelationListV2.tsx delete mode 100644 web/src/components/memo-metadata/SortableItem.tsx create mode 100644 web/src/utils/format.ts diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/LeafletMap.tsx index 23070134c..cd2fd66a2 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/LeafletMap.tsx @@ -34,12 +34,11 @@ const LocationMarker = (props: MarkerProps) => { // Call the parent onChange function. props.onChange(e.latlng); }, - locationfound() {}, + locationfound() { }, }); useEffect(() => { if (!initializedRef.current) { - map.attributionControl.setPrefix(""); map.locate(); initializedRef.current = true; } @@ -247,7 +246,7 @@ const LeafletMap = (props: MapProps) => { isDark ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" } /> - {}} /> + { }} /> diff --git a/web/src/components/MemoEditor/components/AttachmentItemCard.tsx b/web/src/components/MemoEditor/components/AttachmentItemCard.tsx new file mode 100644 index 000000000..3af741db4 --- /dev/null +++ b/web/src/components/MemoEditor/components/AttachmentItemCard.tsx @@ -0,0 +1,116 @@ +import { ChevronDownIcon, ChevronUpIcon, FileIcon, Loader2Icon, XIcon } from "lucide-react"; +import type { FC } from "react"; +import type { AttachmentItem } from "@/components/memo-metadata/types"; +import { cn } from "@/lib/utils"; +import { formatFileSize, getFileTypeLabel } from "@/utils/format"; + +interface AttachmentItemCardProps { + item: AttachmentItem; + onRemove?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + canMoveUp?: boolean; + canMoveDown?: boolean; + className?: string; +} + +const AttachmentItemCard: FC = ({ + item, + onRemove, + onMoveUp, + onMoveDown, + canMoveUp = true, + canMoveDown = true, + className, +}) => { + const { category, filename, thumbnailUrl, mimeType, size, isLocal } = item; + const fileTypeLabel = getFileTypeLabel(mimeType); + const fileSizeLabel = size ? formatFileSize(size) : undefined; + + return ( +
+
+ {category === "image" && thumbnailUrl ? ( + + ) : ( + + )} +
+ +
+ + {filename} + + +
+ {isLocal && ( + <> + + + + )} + {fileTypeLabel} + {fileSizeLabel && ( + <> + + {fileSizeLabel} + + )} +
+
+ +
+ {onMoveUp && ( + + )} + + {onMoveDown && ( + + )} + + {onRemove && ( + + )} +
+
+ ); +}; + +export default AttachmentItemCard; diff --git a/web/src/components/MemoEditor/components/AttachmentListV2.tsx b/web/src/components/MemoEditor/components/AttachmentListV2.tsx new file mode 100644 index 000000000..369a808a5 --- /dev/null +++ b/web/src/components/MemoEditor/components/AttachmentListV2.tsx @@ -0,0 +1,81 @@ +import { PaperclipIcon } from "lucide-react"; +import type { FC } from "react"; +import type { LocalFile } from "@/components/memo-metadata/types"; +import { toAttachmentItems } from "@/components/memo-metadata/types"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import AttachmentItemCard from "./AttachmentItemCard"; + +interface AttachmentListV2Props { + attachments: Attachment[]; + localFiles?: LocalFile[]; + onAttachmentsChange?: (attachments: Attachment[]) => void; + onRemoveLocalFile?: (previewUrl: string) => void; +} + +const AttachmentListV2: FC = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => { + if (attachments.length === 0 && localFiles.length === 0) { + return null; + } + + const items = toAttachmentItems(attachments, localFiles); + + const handleMoveUp = (index: number) => { + if (index === 0 || !onAttachmentsChange) return; + + const newAttachments = [...attachments]; + [newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]]; + onAttachmentsChange(newAttachments); + }; + + const handleMoveDown = (index: number) => { + if (index === attachments.length - 1 || !onAttachmentsChange) return; + + const newAttachments = [...attachments]; + [newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]]; + onAttachmentsChange(newAttachments); + }; + + const handleRemoveAttachment = (name: string) => { + if (onAttachmentsChange) { + onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name)); + } + }; + + const handleRemoveItem = (item: (typeof items)[0]) => { + if (item.isLocal) { + onRemoveLocalFile?.(item.id); + } else { + handleRemoveAttachment(item.id); + } + }; + + return ( +
+
+ + Attachments ({items.length}) +
+ +
+ {items.map((item) => { + const isLocalFile = item.isLocal; + const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); + + return ( + handleRemoveItem(item)} + onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} + onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} + canMoveUp={!isLocalFile && attachmentIndex > 0} + canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} + /> + ); + })} +
+
+ ); +}; + +export default AttachmentListV2; diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx index 555e4358d..bc0fcba4f 100644 --- a/web/src/components/MemoEditor/components/EditorMetadata.tsx +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -1,35 +1,30 @@ import type { FC } from "react"; -import { AttachmentList, LocationDisplay, RelationList } from "@/components/memo-metadata"; import { useEditorContext } from "../state"; import type { EditorMetadataProps } from "../types"; +import AttachmentListV2 from "./AttachmentListV2"; +import LocationDisplayV2 from "./LocationDisplayV2"; +import RelationListV2 from "./RelationListV2"; export const EditorMetadata: FC = () => { const { state, actions, dispatch } = useEditorContext(); return (
- {state.metadata.location && ( - dispatch(actions.setMetadata({ location: undefined }))} - /> - )} - - dispatch(actions.setMetadata({ attachments }))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} /> - dispatch(actions.setMetadata({ relations }))} /> + + {state.metadata.location && ( + dispatch(actions.setMetadata({ location: undefined }))} /> + )}
); }; diff --git a/web/src/components/MemoEditor/components/LocationDisplayV2.tsx b/web/src/components/MemoEditor/components/LocationDisplayV2.tsx new file mode 100644 index 000000000..eccbaf742 --- /dev/null +++ b/web/src/components/MemoEditor/components/LocationDisplayV2.tsx @@ -0,0 +1,48 @@ +import { MapPinIcon, XIcon } from "lucide-react"; +import type { FC } from "react"; +import { cn } from "@/lib/utils"; +import type { Location } from "@/types/proto/api/v1/memo_service_pb"; + +interface LocationDisplayV2Props { + location: Location; + onRemove?: () => void; + className?: string; +} + +const LocationDisplayV2: FC = ({ location, onRemove, className }) => { + const displayText = location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`; + + return ( +
+ + +
+ + {displayText} + + + {location.latitude.toFixed(4)}°, {location.longitude.toFixed(4)}° + +
+ + {onRemove && ( + + )} +
+ ); +}; + +export default LocationDisplayV2; diff --git a/web/src/components/MemoEditor/components/RelationItemCard.tsx b/web/src/components/MemoEditor/components/RelationItemCard.tsx new file mode 100644 index 000000000..9abdd40cd --- /dev/null +++ b/web/src/components/MemoEditor/components/RelationItemCard.tsx @@ -0,0 +1,64 @@ +import { LinkIcon, XIcon } from "lucide-react"; +import type { FC } from "react"; +import { Link } from "react-router-dom"; +import { extractMemoIdFromName } from "@/helpers/resource-names"; +import { cn } from "@/lib/utils"; +import type { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb"; + +interface RelationItemCardProps { + memo: MemoRelation_Memo; + onRemove?: () => void; + parentPage?: string; + className?: string; +} + +const RelationItemCard: FC = ({ memo, onRemove, parentPage, className }) => { + const memoId = extractMemoIdFromName(memo.name); + + if (onRemove) { + return ( +
+ + + {memo.snippet} + + +
+ +
+
+ ); + } + + return ( + + {memoId.slice(0, 6)} + + {memo.snippet} + + + ); +}; + +export default RelationItemCard; diff --git a/web/src/components/MemoEditor/components/RelationListV2.tsx b/web/src/components/MemoEditor/components/RelationListV2.tsx new file mode 100644 index 000000000..cf9d13a03 --- /dev/null +++ b/web/src/components/MemoEditor/components/RelationListV2.tsx @@ -0,0 +1,62 @@ +import { create } from "@bufbuild/protobuf"; +import { LinkIcon } from "lucide-react"; +import type { FC } from "react"; +import { useEffect, useState } from "react"; +import { memoServiceClient } from "@/connect"; +import type { Memo, MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; +import { MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; +import RelationItemCard from "./RelationItemCard"; + +interface RelationListV2Props { + relations: MemoRelation[]; + onRelationsChange?: (relations: MemoRelation[]) => void; +} + +const RelationListV2: FC = ({ relations, onRelationsChange }) => { + const [referencingMemos, setReferencingMemos] = useState([]); + + useEffect(() => { + (async () => { + if (relations.length > 0) { + const requests = relations.map(async (relation) => { + return await memoServiceClient.getMemo({ name: relation.relatedMemo!.name }); + }); + const list = await Promise.all(requests); + setReferencingMemos(list); + } else { + setReferencingMemos([]); + } + })(); + }, [relations]); + + const handleDeleteRelation = (memoName: string) => { + if (onRelationsChange) { + onRelationsChange(relations.filter((relation) => relation.relatedMemo?.name !== memoName)); + } + }; + + if (referencingMemos.length === 0) { + return null; + } + + return ( +
+
+ + Relations ({referencingMemos.length}) +
+ +
+ {referencingMemos.map((memo) => ( + handleDeleteRelation(memo.name)} + /> + ))} +
+
+ ); +}; + +export default RelationListV2; diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts index 75cf6cbb9..be2d6f9e4 100644 --- a/web/src/components/MemoEditor/components/index.ts +++ b/web/src/components/MemoEditor/components/index.ts @@ -1,8 +1,13 @@ // UI components for MemoEditor +export { default as AttachmentItemCard } from "./AttachmentItemCard"; +export { default as AttachmentListV2 } from "./AttachmentListV2"; export * from "./EditorContent"; export * from "./EditorMetadata"; export * from "./EditorToolbar"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { LinkMemoDialog } from "./LinkMemoDialog"; export { LocationDialog } from "./LocationDialog"; +export { default as LocationDisplayV2 } from "./LocationDisplayV2"; +export { default as RelationItemCard } from "./RelationItemCard"; +export { default as RelationListV2 } from "./RelationListV2"; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index a576c56a0..6076f2fb6 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -127,7 +127,7 @@ const MemoEditorImpl: React.FC = ({ */}
= ({ compact, onContentClick, onContentD onDoubleClick={onContentDoubleClick} compact={memo.pinned ? false : compact} // Always show full content when pinned /> - {memo.location && } - - + + + {memo.location && }
diff --git a/web/src/components/memo-metadata/AttachmentCard.tsx b/web/src/components/memo-metadata/AttachmentCard.tsx index 62a8faf2f..baff7cb66 100644 --- a/web/src/components/memo-metadata/AttachmentCard.tsx +++ b/web/src/components/memo-metadata/AttachmentCard.tsx @@ -1,96 +1,32 @@ -import { FileIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { AttachmentItem, DisplayMode } from "./types"; +import type { AttachmentItem } from "./types"; interface AttachmentCardProps { item: AttachmentItem; - mode: DisplayMode; - onRemove?: () => void; + mode: "view"; onClick?: () => void; className?: string; - showThumbnail?: boolean; } -const AttachmentCard = ({ item, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => { - const { category, filename, thumbnailUrl, sourceUrl } = item; - const isMedia = category === "image" || category === "video"; +const AttachmentCard = ({ item, onClick, className }: AttachmentCardProps) => { + const { category, filename, sourceUrl } = item; - // Editor mode - compact badge style with optional thumbnail - if (mode === "edit") { + if (category === "image") { return ( -
- {showThumbnail && category === "image" && thumbnailUrl ? ( - {filename} - ) : ( - - )} - {filename} - {onRemove && ( - - )} -
+ {filename} ); } - // View mode - specialized rendering for media - if (isMedia) { - if (category === "image") { - return ( - { - const target = e.target as HTMLImageElement; - // Fallback to source URL if thumbnail fails - if (target.src.includes("?thumbnail=true")) { - target.src = sourceUrl; - } - }} - onClick={onClick} - decoding="async" - loading="lazy" - alt={filename} - /> - ); - } else if (category === "video") { - return ( -