From 77e9376e03df41d037c39b65482f297098774981 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sat, 3 Jan 2026 13:54:47 +0800 Subject: [PATCH] chore: improve metadata section UI consistency and maintainability --- .../components/metadata/AttachmentList.tsx | 117 +++++++++++++----- .../components/metadata/MetadataCard.tsx | 22 ---- .../components/metadata/RelationCard.tsx | 6 +- .../components/metadata/RelationList.tsx | 117 ++++++++---------- .../components/metadata/SectionHeader.tsx | 48 +++++++ .../MemoView/components/metadata/index.ts | 2 +- 6 files changed, 193 insertions(+), 119 deletions(-) delete mode 100644 web/src/components/MemoView/components/metadata/MetadataCard.tsx create mode 100644 web/src/components/MemoView/components/metadata/SectionHeader.tsx diff --git a/web/src/components/MemoView/components/metadata/AttachmentList.tsx b/web/src/components/MemoView/components/metadata/AttachmentList.tsx index 522072790..7783725fc 100644 --- a/web/src/components/MemoView/components/metadata/AttachmentList.tsx +++ b/web/src/components/MemoView/components/metadata/AttachmentList.tsx @@ -1,15 +1,17 @@ +import { FileIcon, PaperclipIcon } from "lucide-react"; import { useState } from "react"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; -import MemoAttachment from "../../../MemoAttachment"; +import { formatFileSize, getFileTypeLabel } from "@/utils/format"; import PreviewImageDialog from "../../../PreviewImageDialog"; import AttachmentCard from "./AttachmentCard"; +import SectionHeader from "./SectionHeader"; interface AttachmentListProps { attachments: Attachment[]; } -function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[]; docs: Attachment[] } { +const separateMediaAndDocs = (attachments: Attachment[]): { media: Attachment[]; docs: Attachment[] } => { const media: Attachment[] = []; const docs: Attachment[] = []; @@ -23,7 +25,70 @@ function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[]; } return { media, docs }; -} +}; + +const DocumentItem = ({ attachment }: { attachment: Attachment }) => { + const fileTypeLabel = getFileTypeLabel(attachment.type); + const fileSizeLabel = attachment.size ? formatFileSize(Number(attachment.size)) : undefined; + + return ( +
+
+ +
+
+ + {attachment.filename} + +
+ + {fileTypeLabel} + {fileSizeLabel && ( + <> + + {fileSizeLabel} + + )} +
+
+
+ ); +}; + +const MediaGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick: (url: string) => void }) => ( +
+ {attachments.map((attachment) => ( +
onImageClick(getAttachmentUrl(attachment))} + > +
+ + {getAttachmentType(attachment) === "video/*" && ( +
+
+ + + +
+
+ )} +
+
+ ))} +
+); + +const DocsList = ({ attachments }: { attachments: Attachment[] }) => ( +
+ {attachments.map((attachment) => ( + + + + ))} +
+); const AttachmentList = ({ attachments }: AttachmentListProps) => { const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({ @@ -33,45 +98,33 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => { mimeType: undefined, }); - const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => { - const imageAttachments = mediaAttachments.filter((attachment) => getAttachmentType(attachment) === "image/*"); - const imgUrls = imageAttachments.map((attachment) => getAttachmentUrl(attachment)); - const index = imgUrls.findIndex((url) => url === imgUrl); - const mimeType = imageAttachments[index]?.type; - setPreviewImage({ open: true, urls: imgUrls, index, mimeType }); - }; - const { media: mediaItems, docs: docItems } = separateMediaAndDocs(attachments); if (attachments.length === 0) { return null; } + const handleImageClick = (imgUrl: string) => { + const imageAttachments = mediaItems.filter((a) => getAttachmentType(a) === "image/*"); + const imgUrls = imageAttachments.map((a) => getAttachmentUrl(a)); + const index = imgUrls.findIndex((url) => url === imgUrl); + const mimeType = imageAttachments[index]?.type; + setPreviewImage({ open: true, urls: imgUrls, index, mimeType }); + }; + return ( <> - {mediaItems.length > 0 && ( -
- {mediaItems.map((attachment) => ( -
- { - handleImageClick(getAttachmentUrl(attachment), mediaItems); - }} - className="max-h-64 grow" - /> -
- ))} -
- )} +
+ - {docItems.length > 0 && ( -
- {docItems.map((attachment) => ( - - ))} +
+ {mediaItems.length > 0 && } + + {mediaItems.length > 0 && docItems.length > 0 &&
} + + {docItems.length > 0 && }
- )} +
{ - return ( -
- {children} -
- ); -}; - -export default MetadataCard; diff --git a/web/src/components/MemoView/components/metadata/RelationCard.tsx b/web/src/components/MemoView/components/metadata/RelationCard.tsx index 5e35fc23e..c81df3441 100644 --- a/web/src/components/MemoView/components/metadata/RelationCard.tsx +++ b/web/src/components/MemoView/components/metadata/RelationCard.tsx @@ -15,14 +15,16 @@ const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => { return ( - {memoId.slice(0, 6)} + + {memoId.slice(0, 6)} + {memo.snippet} ); diff --git a/web/src/components/MemoView/components/metadata/RelationList.tsx b/web/src/components/MemoView/components/metadata/RelationList.tsx index 0a6a12903..4ce02cbda 100644 --- a/web/src/components/MemoView/components/metadata/RelationList.tsx +++ b/web/src/components/MemoView/components/metadata/RelationList.tsx @@ -1,11 +1,11 @@ import { LinkIcon, MilestoneIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { cn } from "@/lib/utils"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; -import MetadataCard from "./MetadataCard"; import RelationCard from "./RelationCard"; +import SectionHeader from "./SectionHeader"; interface RelationListProps { relations: MemoRelation[]; @@ -16,75 +16,68 @@ interface RelationListProps { function RelationList({ relations, currentMemoName, parentPage, className }: RelationListProps) { const t = useTranslate(); - const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing"); + const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing"); - const referencingRelations = relations.filter( - (relation) => - relation.type === MemoRelation_Type.REFERENCE && - relation.memo?.name === currentMemoName && - relation.relatedMemo?.name !== currentMemoName, - ); - - const referencedRelations = relations.filter( - (relation) => - relation.type === MemoRelation_Type.REFERENCE && - relation.memo?.name !== currentMemoName && - relation.relatedMemo?.name === currentMemoName, - ); + const { referencingRelations, referencedRelations } = useMemo(() => { + return { + referencingRelations: relations.filter( + (r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name === currentMemoName && r.relatedMemo?.name !== currentMemoName, + ), + referencedRelations: relations.filter( + (r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name !== currentMemoName && r.relatedMemo?.name === currentMemoName, + ), + }; + }, [relations, currentMemoName]); if (referencingRelations.length === 0 && referencedRelations.length === 0) { return null; } - const activeTab = referencingRelations.length === 0 ? "referenced" : selectedTab; + const hasBothTabs = referencingRelations.length > 0 && referencedRelations.length > 0; + const defaultTab = referencingRelations.length > 0 ? "referencing" : "referenced"; + const tab = hasBothTabs ? activeTab : defaultTab; + const isReferencing = tab === "referencing"; + const icon = isReferencing ? LinkIcon : MilestoneIcon; + const activeRelations = isReferencing ? referencingRelations : referencedRelations; return ( - -
- {referencingRelations.length > 0 && ( - - )} - {referencedRelations.length > 0 && ( - - )} +
+ setActiveTab("referencing"), + }, + { + id: "referenced", + label: t("common.referenced-by"), + count: referencedRelations.length, + active: !isReferencing, + onClick: () => setActiveTab("referenced"), + }, + ] + : undefined + } + /> + +
+ {activeRelations.map((relation) => ( + + ))}
- - {activeTab === "referencing" && referencingRelations.length > 0 && ( -
- {referencingRelations.map((relation) => ( - - ))} -
- )} - - {activeTab === "referenced" && referencedRelations.length > 0 && ( -
- {referencedRelations.map((relation) => ( - - ))} -
- )} - +
); } diff --git a/web/src/components/MemoView/components/metadata/SectionHeader.tsx b/web/src/components/MemoView/components/metadata/SectionHeader.tsx new file mode 100644 index 000000000..11e153232 --- /dev/null +++ b/web/src/components/MemoView/components/metadata/SectionHeader.tsx @@ -0,0 +1,48 @@ +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SectionHeaderProps { + icon: LucideIcon; + title: string; + count: number; + tabs?: Array<{ + id: string; + label: string; + count: number; + active: boolean; + onClick: () => void; + }>; +} + +const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) => { + return ( +
+ + + {tabs && tabs.length > 1 ? ( +
+ {tabs.map((tab, idx) => ( +
+ + {idx < tabs.length - 1 && /} +
+ ))} +
+ ) : ( + + {title} ({count}) + + )} +
+ ); +}; + +export default SectionHeader; diff --git a/web/src/components/MemoView/components/metadata/index.ts b/web/src/components/MemoView/components/metadata/index.ts index 69a27257f..634e4563c 100644 --- a/web/src/components/MemoView/components/metadata/index.ts +++ b/web/src/components/MemoView/components/metadata/index.ts @@ -1,6 +1,6 @@ export { default as AttachmentCard } from "./AttachmentCard"; export { default as AttachmentList } from "./AttachmentList"; export { default as LocationDisplay } from "./LocationDisplay"; -export { default as MetadataCard } from "./MetadataCard"; + export { default as RelationCard } from "./RelationCard"; export { default as RelationList } from "./RelationList";