mirror of https://github.com/usememos/memos.git
chore: improve metadata section UI consistency and maintainability
This commit is contained in:
parent
5612fb8f41
commit
77e9376e03
|
|
@ -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 (
|
||||
<div className="flex items-center gap-1 px-1.5 py-1 rounded hover:bg-accent/20 transition-colors whitespace-nowrap">
|
||||
<div className="shrink-0 w-5 h-5 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
|
||||
<FileIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-xs font-medium truncate" title={attachment.filename}>
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>{fileTypeLabel}</span>
|
||||
{fileSizeLabel && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
<span>{fileSizeLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick: (url: string) => void }) => (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div
|
||||
key={attachment.name}
|
||||
className="aspect-square rounded-lg overflow-hidden bg-muted/40 border border-border hover:border-accent/50 transition-all cursor-pointer group"
|
||||
onClick={() => onImageClick(getAttachmentUrl(attachment))}
|
||||
>
|
||||
<div className="w-full h-full relative">
|
||||
<AttachmentCard attachment={attachment} className="rounded-none" />
|
||||
{getAttachmentType(attachment) === "video/*" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/40 transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-white/80 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-black fill-current ml-0.5" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{attachments.map((attachment) => (
|
||||
<a key={attachment.name} href={getAttachmentUrl(attachment)} download title={`Download ${attachment.filename}`}>
|
||||
<DocumentItem attachment={attachment} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 && (
|
||||
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
|
||||
{mediaItems.map((attachment) => (
|
||||
<div key={attachment.name} className="max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0">
|
||||
<AttachmentCard
|
||||
attachment={attachment}
|
||||
onClick={() => {
|
||||
handleImageClick(getAttachmentUrl(attachment), mediaItems);
|
||||
}}
|
||||
className="max-h-64 grow"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
|
||||
|
||||
{docItems.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
|
||||
{docItems.map((attachment) => (
|
||||
<MemoAttachment key={attachment.name} attachment={attachment} />
|
||||
))}
|
||||
<div className="p-2 flex flex-col gap-1">
|
||||
{mediaItems.length > 0 && <MediaGrid attachments={mediaItems} onImageClick={handleImageClick} />}
|
||||
|
||||
{mediaItems.length > 0 && docItems.length > 0 && <div className="border-t border-border opacity-60" />}
|
||||
|
||||
{docItems.length > 0 && <DocsList attachments={docItems} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MetadataCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MetadataCard = ({ children, className }: MetadataCardProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col justify-start items-start w-full px-2 pt-2 pb-1.5 bg-muted/50 rounded-lg border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataCard;
|
||||
|
|
@ -15,14 +15,16 @@ const RelationCard = ({ memo, parentPage, className }: RelationCardProps) => {
|
|||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"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",
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
to={`/${memo.name}`}
|
||||
viewTransition
|
||||
state={{ from: parentPage }}
|
||||
>
|
||||
<span className="text-[10px] opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1">{memoId.slice(0, 6)}</span>
|
||||
<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">
|
||||
{memoId.slice(0, 6)}
|
||||
</span>
|
||||
<span className="truncate">{memo.snippet}</span>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<MetadataCard className={className}>
|
||||
<div className="w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60">
|
||||
{referencingRelations.length > 0 && (
|
||||
<button
|
||||
className={cn(
|
||||
"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",
|
||||
activeTab === "referencing" && "text-foreground bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedTab("referencing")}
|
||||
>
|
||||
<LinkIcon className="w-3 h-auto shrink-0 opacity-70" />
|
||||
<span>{t("common.referencing")}</span>
|
||||
<span className="opacity-80">({referencingRelations.length})</span>
|
||||
</button>
|
||||
)}
|
||||
{referencedRelations.length > 0 && (
|
||||
<button
|
||||
className={cn(
|
||||
"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",
|
||||
activeTab === "referenced" && "text-foreground bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedTab("referenced")}
|
||||
>
|
||||
<MilestoneIcon className="w-3 h-auto shrink-0 opacity-70" />
|
||||
<span>{t("common.referenced-by")}</span>
|
||||
<span className="opacity-80">({referencedRelations.length})</span>
|
||||
</button>
|
||||
)}
|
||||
<div className={cn("w-full rounded-lg border border-border bg-muted/20 overflow-hidden", className)}>
|
||||
<SectionHeader
|
||||
icon={icon}
|
||||
title={isReferencing ? t("common.referencing") : t("common.referenced-by")}
|
||||
count={activeRelations.length}
|
||||
tabs={
|
||||
hasBothTabs
|
||||
? [
|
||||
{
|
||||
id: "referencing",
|
||||
label: t("common.referencing"),
|
||||
count: referencingRelations.length,
|
||||
active: isReferencing,
|
||||
onClick: () => setActiveTab("referencing"),
|
||||
},
|
||||
{
|
||||
id: "referenced",
|
||||
label: t("common.referenced-by"),
|
||||
count: referencedRelations.length,
|
||||
active: !isReferencing,
|
||||
onClick: () => setActiveTab("referenced"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-1.5 flex flex-col gap-0">
|
||||
{activeRelations.map((relation) => (
|
||||
<RelationCard
|
||||
key={isReferencing ? relation.relatedMemo!.name : relation.memo!.name}
|
||||
memo={isReferencing ? relation.relatedMemo! : relation.memo!}
|
||||
parentPage={parentPage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "referencing" && referencingRelations.length > 0 && (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{referencingRelations.map((relation) => (
|
||||
<RelationCard key={relation.relatedMemo!.name} memo={relation.relatedMemo!} parentPage={parentPage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "referenced" && referencedRelations.length > 0 && (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{referencedRelations.map((relation) => (
|
||||
<RelationCard key={relation.memo!.name} memo={relation.memo!} parentPage={parentPage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetadataCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/30">
|
||||
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
|
||||
{tabs && tabs.length > 1 ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{tabs.map((tab, idx) => (
|
||||
<div key={tab.id} className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={tab.onClick}
|
||||
className={cn(
|
||||
"text-xs font-medium px-0 py-0 transition-colors",
|
||||
tab.active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}({tab.count})
|
||||
</button>
|
||||
{idx < tabs.length - 1 && <span className="text-muted-foreground/50">/</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{title} ({count})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionHeader;
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue