memos/web/src/components/memo-metadata/AttachmentList.tsx
Johnny fc43f86571
chore(web): unify Location/Attachments/Relations components (#5241)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-09 07:23:31 +08:00

144 lines
5.2 KiB
TypeScript

import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useState } from "react";
import { Attachment } from "@/types/proto/api/v1/attachment_service";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
import MemoAttachment from "../MemoAttachment";
import SortableItem from "../MemoEditor/SortableItem";
import PreviewImageDialog from "../PreviewImageDialog";
import AttachmentCard from "./AttachmentCard";
import { BaseMetadataProps } from "./types";
interface AttachmentListProps extends BaseMetadataProps {
attachments: Attachment[];
onAttachmentsChange?: (attachments: Attachment[]) => void;
}
/**
* Unified AttachmentList component for both editor and view modes
*
* Editor mode:
* - Shows all attachments as sortable badges with thumbnails
* - Supports drag-and-drop reordering
* - Shows remove buttons
*
* View mode:
* - Separates media (images/videos) from other files
* - Shows media in gallery layout with preview
* - Shows other files as clickable cards
*/
const AttachmentList = ({ attachments, mode, onAttachmentsChange }: AttachmentListProps) => {
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
open: false,
urls: [],
index: 0,
});
const handleDeleteAttachment = (name: string) => {
if (onAttachmentsChange) {
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id && onAttachmentsChange) {
const oldIndex = attachments.findIndex((attachment) => attachment.name === active.id);
const newIndex = attachments.findIndex((attachment) => attachment.name === over.id);
onAttachmentsChange(arrayMove(attachments, oldIndex, newIndex));
}
};
const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => {
const imgUrls = mediaAttachments
.filter((attachment) => getAttachmentType(attachment) === "image/*")
.map((attachment) => getAttachmentUrl(attachment));
const index = imgUrls.findIndex((url) => url === imgUrl);
setPreviewImage({ open: true, urls: imgUrls, index });
};
// Editor mode: Show all attachments as sortable badges
if (mode === "edit") {
if (attachments.length === 0) {
return null;
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={attachments.map((attachment) => attachment.name)} strategy={verticalListSortingStrategy}>
<div className="w-full flex flex-row justify-start flex-wrap gap-2 mt-2 max-h-[50vh] overflow-y-auto">
{attachments.map((attachment) => (
<div key={attachment.name}>
<SortableItem id={attachment.name} className="flex items-center gap-1.5 min-w-0">
<AttachmentCard
attachment={attachment}
mode="edit"
onRemove={() => handleDeleteAttachment(attachment.name)}
showThumbnail={true}
/>
</SortableItem>
</div>
))}
</div>
</SortableContext>
</DndContext>
);
}
// View mode: Separate media from other files
const mediaAttachments: Attachment[] = [];
const otherAttachments: Attachment[] = [];
attachments.forEach((attachment) => {
const type = getAttachmentType(attachment);
if (type === "image/*" || type === "video/*") {
mediaAttachments.push(attachment);
} else {
otherAttachments.push(attachment);
}
});
return (
<>
{/* Media Gallery */}
{mediaAttachments.length > 0 && (
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
{mediaAttachments.map((attachment) => (
<div key={attachment.name} className="max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0">
<AttachmentCard
attachment={attachment}
mode="view"
onClick={() => {
const attachmentUrl = getAttachmentUrl(attachment);
handleImageClick(attachmentUrl, mediaAttachments);
}}
className="max-h-64 grow"
/>
</div>
))}
</div>
)}
{/* Other Files */}
{otherAttachments.length > 0 && (
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
{otherAttachments.map((attachment) => (
<MemoAttachment key={attachment.name} attachment={attachment} />
))}
</div>
)}
{/* Image Preview Dialog */}
<PreviewImageDialog
open={previewImage.open}
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
imgUrls={previewImage.urls}
initialIndex={previewImage.index}
/>
</>
);
};
export default AttachmentList;