Skip to content

Commit 77e9376

Browse files
committed
chore: improve metadata section UI consistency and maintainability
1 parent 5612fb8 commit 77e9376

File tree

6 files changed

+193
-119
lines changed

6 files changed

+193
-119
lines changed

web/src/components/MemoView/components/metadata/AttachmentList.tsx

Lines changed: 85 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1+
import { FileIcon, PaperclipIcon } from "lucide-react";
12
import { useState } from "react";
23
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
34
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
4-
import MemoAttachment from "../../../MemoAttachment";
5+
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
56
import PreviewImageDialog from "../../../PreviewImageDialog";
67
import AttachmentCard from "./AttachmentCard";
8+
import SectionHeader from "./SectionHeader";
79

810
interface AttachmentListProps {
911
attachments: Attachment[];
1012
}
1113

12-
function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[]; docs: Attachment[] } {
14+
const separateMediaAndDocs = (attachments: Attachment[]): { media: Attachment[]; docs: Attachment[] } => {
1315
const media: Attachment[] = [];
1416
const docs: Attachment[] = [];
1517

@@ -23,7 +25,70 @@ function separateMediaAndDocs(attachments: Attachment[]): { media: Attachment[];
2325
}
2426

2527
return { media, docs };
26-
}
28+
};
29+
30+
const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
31+
const fileTypeLabel = getFileTypeLabel(attachment.type);
32+
const fileSizeLabel = attachment.size ? formatFileSize(Number(attachment.size)) : undefined;
33+
34+
return (
35+
<div className="flex items-center gap-1 px-1.5 py-1 rounded hover:bg-accent/20 transition-colors whitespace-nowrap">
36+
<div className="shrink-0 w-5 h-5 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
37+
<FileIcon className="w-3 h-3 text-muted-foreground" />
38+
</div>
39+
<div className="flex items-center gap-1 min-w-0">
40+
<span className="text-xs font-medium truncate" title={attachment.filename}>
41+
{attachment.filename}
42+
</span>
43+
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
44+
<span className="text-muted-foreground/50"></span>
45+
<span>{fileTypeLabel}</span>
46+
{fileSizeLabel && (
47+
<>
48+
<span className="text-muted-foreground/50"></span>
49+
<span>{fileSizeLabel}</span>
50+
</>
51+
)}
52+
</div>
53+
</div>
54+
</div>
55+
);
56+
};
57+
58+
const MediaGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick: (url: string) => void }) => (
59+
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
60+
{attachments.map((attachment) => (
61+
<div
62+
key={attachment.name}
63+
className="aspect-square rounded-lg overflow-hidden bg-muted/40 border border-border hover:border-accent/50 transition-all cursor-pointer group"
64+
onClick={() => onImageClick(getAttachmentUrl(attachment))}
65+
>
66+
<div className="w-full h-full relative">
67+
<AttachmentCard attachment={attachment} className="rounded-none" />
68+
{getAttachmentType(attachment) === "video/*" && (
69+
<div className="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/40 transition-colors">
70+
<div className="w-8 h-8 rounded-full bg-white/80 flex items-center justify-center">
71+
<svg className="w-5 h-5 text-black fill-current ml-0.5" viewBox="0 0 24 24">
72+
<path d="M8 5v14l11-7z" />
73+
</svg>
74+
</div>
75+
</div>
76+
)}
77+
</div>
78+
</div>
79+
))}
80+
</div>
81+
);
82+
83+
const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
84+
<div className="flex flex-col gap-0.5">
85+
{attachments.map((attachment) => (
86+
<a key={attachment.name} href={getAttachmentUrl(attachment)} download title={`Download ${attachment.filename}`}>
87+
<DocumentItem attachment={attachment} />
88+
</a>
89+
))}
90+
</div>
91+
);
2792

2893
const AttachmentList = ({ attachments }: AttachmentListProps) => {
2994
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({
@@ -33,45 +98,33 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
3398
mimeType: undefined,
3499
});
35100

36-
const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => {
37-
const imageAttachments = mediaAttachments.filter((attachment) => getAttachmentType(attachment) === "image/*");
38-
const imgUrls = imageAttachments.map((attachment) => getAttachmentUrl(attachment));
39-
const index = imgUrls.findIndex((url) => url === imgUrl);
40-
const mimeType = imageAttachments[index]?.type;
41-
setPreviewImage({ open: true, urls: imgUrls, index, mimeType });
42-
};
43-
44101
const { media: mediaItems, docs: docItems } = separateMediaAndDocs(attachments);
45102

46103
if (attachments.length === 0) {
47104
return null;
48105
}
49106

107+
const handleImageClick = (imgUrl: string) => {
108+
const imageAttachments = mediaItems.filter((a) => getAttachmentType(a) === "image/*");
109+
const imgUrls = imageAttachments.map((a) => getAttachmentUrl(a));
110+
const index = imgUrls.findIndex((url) => url === imgUrl);
111+
const mimeType = imageAttachments[index]?.type;
112+
setPreviewImage({ open: true, urls: imgUrls, index, mimeType });
113+
};
114+
50115
return (
51116
<>
52-
{mediaItems.length > 0 && (
53-
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
54-
{mediaItems.map((attachment) => (
55-
<div key={attachment.name} className="max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0">
56-
<AttachmentCard
57-
attachment={attachment}
58-
onClick={() => {
59-
handleImageClick(getAttachmentUrl(attachment), mediaItems);
60-
}}
61-
className="max-h-64 grow"
62-
/>
63-
</div>
64-
))}
65-
</div>
66-
)}
117+
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
118+
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
119+
120+
<div className="p-2 flex flex-col gap-1">
121+
{mediaItems.length > 0 && <MediaGrid attachments={mediaItems} onImageClick={handleImageClick} />}
122+
123+
{mediaItems.length > 0 && docItems.length > 0 && <div className="border-t border-border opacity-60" />}
67124

68-
{docItems.length > 0 && (
69-
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
70-
{docItems.map((attachment) => (
71-
<MemoAttachment key={attachment.name} attachment={attachment} />
72-
))}
125+
{docItems.length > 0 && <DocsList attachments={docItems} />}
73126
</div>
74-
)}
127+
</div>
75128

76129
<PreviewImageDialog
77130
open={previewImage.open}

web/src/components/MemoView/components/metadata/MetadataCard.tsx

Lines changed: 0 additions & 22 deletions
This file was deleted.

web/src/components/MemoView/components/metadata/RelationCard.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@ const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
1515
return (
1616
<Link
1717
className={cn(
18-
"w-full flex flex-row justify-start items-center text-sm leading-5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-1 transition-colors",
18+
"flex items-center gap-1 px-1 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-colors group",
1919
className,
2020
)}
2121
to={`/${memo.name}`}
2222
viewTransition
2323
state={{ from: parentPage }}
2424
>
25-
<span className="text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1">{memoId.slice(0, 6)}</span>
25+
<span className="text-[8px] font-mono px-1 py-0.5 rounded border border-border bg-muted/40 group-hover:bg-accent/30 transition-colors shrink-0">
26+
{memoId.slice(0, 6)}
27+
</span>
2628
<span className="truncate">{memo.snippet}</span>
2729
</Link>
2830
);

web/src/components/MemoView/components/metadata/RelationList.tsx

Lines changed: 55 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { LinkIcon, MilestoneIcon } from "lucide-react";
2-
import { useState } from "react";
2+
import { useMemo, useState } from "react";
33
import { cn } from "@/lib/utils";
44
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
55
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
66
import { useTranslate } from "@/utils/i18n";
7-
import MetadataCard from "./MetadataCard";
87
import RelationCard from "./RelationCard";
8+
import SectionHeader from "./SectionHeader";
99

1010
interface RelationListProps {
1111
relations: MemoRelation[];
@@ -16,75 +16,68 @@ interface RelationListProps {
1616

1717
function RelationList({ relations, currentMemoName, parentPage, className }: RelationListProps) {
1818
const t = useTranslate();
19-
const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
19+
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
2020

21-
const referencingRelations = relations.filter(
22-
(relation) =>
23-
relation.type === MemoRelation_Type.REFERENCE &&
24-
relation.memo?.name === currentMemoName &&
25-
relation.relatedMemo?.name !== currentMemoName,
26-
);
27-
28-
const referencedRelations = relations.filter(
29-
(relation) =>
30-
relation.type === MemoRelation_Type.REFERENCE &&
31-
relation.memo?.name !== currentMemoName &&
32-
relation.relatedMemo?.name === currentMemoName,
33-
);
21+
const { referencingRelations, referencedRelations } = useMemo(() => {
22+
return {
23+
referencingRelations: relations.filter(
24+
(r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name === currentMemoName && r.relatedMemo?.name !== currentMemoName,
25+
),
26+
referencedRelations: relations.filter(
27+
(r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name !== currentMemoName && r.relatedMemo?.name === currentMemoName,
28+
),
29+
};
30+
}, [relations, currentMemoName]);
3431

3532
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
3633
return null;
3734
}
3835

39-
const activeTab = referencingRelations.length === 0 ? "referenced" : selectedTab;
36+
const hasBothTabs = referencingRelations.length > 0 && referencedRelations.length > 0;
37+
const defaultTab = referencingRelations.length > 0 ? "referencing" : "referenced";
38+
const tab = hasBothTabs ? activeTab : defaultTab;
39+
const isReferencing = tab === "referencing";
40+
const icon = isReferencing ? LinkIcon : MilestoneIcon;
41+
const activeRelations = isReferencing ? referencingRelations : referencedRelations;
4042

4143
return (
42-
<MetadataCard className={className}>
43-
<div className="w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60">
44-
{referencingRelations.length > 0 && (
45-
<button
46-
className={cn(
47-
"w-auto flex flex-row justify-start items-center text-xs gap-0.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-0.5 transition-colors",
48-
activeTab === "referencing" && "text-foreground bg-accent",
49-
)}
50-
onClick={() => setSelectedTab("referencing")}
51-
>
52-
<LinkIcon className="w-3 h-auto shrink-0 opacity-70" />
53-
<span>{t("common.referencing")}</span>
54-
<span className="opacity-80">({referencingRelations.length})</span>
55-
</button>
56-
)}
57-
{referencedRelations.length > 0 && (
58-
<button
59-
className={cn(
60-
"w-auto flex flex-row justify-start items-center text-xs gap-0.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-0.5 transition-colors",
61-
activeTab === "referenced" && "text-foreground bg-accent",
62-
)}
63-
onClick={() => setSelectedTab("referenced")}
64-
>
65-
<MilestoneIcon className="w-3 h-auto shrink-0 opacity-70" />
66-
<span>{t("common.referenced-by")}</span>
67-
<span className="opacity-80">({referencedRelations.length})</span>
68-
</button>
69-
)}
70-
</div>
71-
72-
{activeTab === "referencing" && referencingRelations.length > 0 && (
73-
<div className="w-full flex flex-col justify-start items-start">
74-
{referencingRelations.map((relation) => (
75-
<RelationCard key={relation.relatedMemo!.name} memo={relation.relatedMemo!} parentPage={parentPage} />
76-
))}
77-
</div>
78-
)}
44+
<div className={cn("w-full rounded-lg border border-border bg-muted/20 overflow-hidden", className)}>
45+
<SectionHeader
46+
icon={icon}
47+
title={isReferencing ? t("common.referencing") : t("common.referenced-by")}
48+
count={activeRelations.length}
49+
tabs={
50+
hasBothTabs
51+
? [
52+
{
53+
id: "referencing",
54+
label: t("common.referencing"),
55+
count: referencingRelations.length,
56+
active: isReferencing,
57+
onClick: () => setActiveTab("referencing"),
58+
},
59+
{
60+
id: "referenced",
61+
label: t("common.referenced-by"),
62+
count: referencedRelations.length,
63+
active: !isReferencing,
64+
onClick: () => setActiveTab("referenced"),
65+
},
66+
]
67+
: undefined
68+
}
69+
/>
7970

80-
{activeTab === "referenced" && referencedRelations.length > 0 && (
81-
<div className="w-full flex flex-col justify-start items-start">
82-
{referencedRelations.map((relation) => (
83-
<RelationCard key={relation.memo!.name} memo={relation.memo!} parentPage={parentPage} />
84-
))}
85-
</div>
86-
)}
87-
</MetadataCard>
71+
<div className="p-1.5 flex flex-col gap-0">
72+
{activeRelations.map((relation) => (
73+
<RelationCard
74+
key={isReferencing ? relation.relatedMemo!.name : relation.memo!.name}
75+
memo={isReferencing ? relation.relatedMemo! : relation.memo!}
76+
parentPage={parentPage}
77+
/>
78+
))}
79+
</div>
80+
</div>
8881
);
8982
}
9083

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { LucideIcon } from "lucide-react";
2+
import { cn } from "@/lib/utils";
3+
4+
interface SectionHeaderProps {
5+
icon: LucideIcon;
6+
title: string;
7+
count: number;
8+
tabs?: Array<{
9+
id: string;
10+
label: string;
11+
count: number;
12+
active: boolean;
13+
onClick: () => void;
14+
}>;
15+
}
16+
17+
const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) => {
18+
return (
19+
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/30">
20+
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
21+
22+
{tabs && tabs.length > 1 ? (
23+
<div className="flex items-center gap-0.5">
24+
{tabs.map((tab, idx) => (
25+
<div key={tab.id} className="flex items-center gap-0.5">
26+
<button
27+
onClick={tab.onClick}
28+
className={cn(
29+
"text-xs font-medium px-0 py-0 transition-colors",
30+
tab.active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
31+
)}
32+
>
33+
{tab.label}({tab.count})
34+
</button>
35+
{idx < tabs.length - 1 && <span className="text-muted-foreground/50">/</span>}
36+
</div>
37+
))}
38+
</div>
39+
) : (
40+
<span className="text-xs font-medium text-foreground">
41+
{title} ({count})
42+
</span>
43+
)}
44+
</div>
45+
);
46+
};
47+
48+
export default SectionHeader;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { default as AttachmentCard } from "./AttachmentCard";
22
export { default as AttachmentList } from "./AttachmentList";
33
export { default as LocationDisplay } from "./LocationDisplay";
4-
export { default as MetadataCard } from "./MetadataCard";
4+
55
export { default as RelationCard } from "./RelationCard";
66
export { default as RelationList } from "./RelationList";

0 commit comments

Comments
 (0)