From 124708f16430bf80b21a120e7de668c3d028794d Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 8 Apr 2026 23:28:49 +0800 Subject: [PATCH] chore: refactor attachment media layout and insert menu organization --- .../MemoEditor/Toolbar/InsertMenu.tsx | 29 ++- .../MemoEditor/hooks/useFileUpload.ts | 9 +- .../Attachment/AttachmentListView.tsx | 236 +++++++++++++----- .../Attachment/AudioAttachmentItem.tsx | 35 +-- web/src/components/MotionPhotoPlayer.tsx | 7 +- web/src/components/MotionPhotoPreview.tsx | 6 + 6 files changed, 230 insertions(+), 92 deletions(-) diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 63da079c5..baac1084d 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -2,6 +2,7 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; import { FileIcon, + ImageIcon, LinkIcon, LoaderIcon, type LucideIcon, @@ -131,14 +132,22 @@ const InsertMenu = (props: InsertMenuProps) => { setMoreSubmenuOpen(false); }, [onToggleFocusMode]); + const handleMediaUploadClick = useCallback(() => { + handleUploadClick("image/*,video/*"); + }, [handleUploadClick]); + + const handleFileUploadClick = useCallback(() => { + handleUploadClick(); + }, [handleUploadClick]); + const menuItems = useMemo( () => [ { - key: "upload", - label: t("editor.insert-menu.upload-file"), - icon: FileIcon, - onClick: handleUploadClick, + key: "upload-media", + label: t("attachment-library.tabs.media"), + icon: ImageIcon, + onClick: handleMediaUploadClick, }, { key: "record-audio", @@ -146,6 +155,12 @@ const InsertMenu = (props: InsertMenuProps) => { icon: MicIcon, onClick: () => props.onAudioRecorderClick?.(), }, + { + key: "upload-file", + label: t("common.file"), + icon: FileIcon, + onClick: handleFileUploadClick, + }, { key: "link", label: t("editor.insert-menu.link-memo"), @@ -159,7 +174,7 @@ const InsertMenu = (props: InsertMenuProps) => { onClick: handleLocationClick, }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, - [handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t], + [handleFileUploadClick, handleLocationClick, handleMediaUploadClick, handleOpenLinkDialog, props, t], ); return ( @@ -171,14 +186,14 @@ const InsertMenu = (props: InsertMenuProps) => { - {menuItems.slice(0, 2).map((item) => ( + {menuItems.slice(0, 3).map((item) => ( {item.label} ))} - {menuItems.slice(2).map((item) => ( + {menuItems.slice(3).map((item) => ( {item.label} diff --git a/web/src/components/MemoEditor/hooks/useFileUpload.ts b/web/src/components/MemoEditor/hooks/useFileUpload.ts index 7d867510b..ae69db2b3 100644 --- a/web/src/components/MemoEditor/hooks/useFileUpload.ts +++ b/web/src/components/MemoEditor/hooks/useFileUpload.ts @@ -26,8 +26,13 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void if (fileInputRef.current) fileInputRef.current.value = ""; }; - const handleUploadClick = () => { - fileInputRef.current?.click(); + const handleUploadClick = (accept = "*") => { + if (!fileInputRef.current) { + return; + } + + fileInputRef.current.accept = accept; + fileInputRef.current.click(); }; return { diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx index 37f96e009..735d4b0e0 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx @@ -1,11 +1,12 @@ import { DownloadIcon, FileIcon, PaperclipIcon, PlayIcon } from "lucide-react"; +import type { PropsWithChildren } from "react"; import { useMemo } from "react"; import MetadataSection from "@/components/MemoMetadata/MetadataSection"; import MotionPhotoPreview from "@/components/MotionPhotoPreview"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { getAttachmentUrl } from "@/utils/attachment"; -import type { PreviewMediaItem } from "@/utils/media-item"; +import type { AttachmentVisualItem, PreviewMediaItem } from "@/utils/media-item"; import { buildAttachmentVisualItems } from "@/utils/media-item"; import AudioAttachmentItem from "./AudioAttachmentItem"; import { getAttachmentMetadata, isAudioAttachment, separateAttachments } from "./attachmentHelpers"; @@ -15,6 +16,17 @@ interface AttachmentListViewProps { onImagePreview?: (items: PreviewMediaItem[], index: number) => void; } +type VisualItem = AttachmentVisualItem; + +const VISUAL_TILE_CLASS = + "group relative overflow-hidden rounded-xl border border-border/70 bg-muted/30 text-left transition-colors hover:border-accent/40"; +const COVER_MEDIA_CLASS = "h-full w-full rounded-none object-cover transition-transform duration-300 group-hover:scale-[1.02]"; +const NATURAL_MEDIA_CLASS = + "block h-auto max-h-[20rem] w-auto max-w-full rounded-none transition-transform duration-300 group-hover:scale-[1.02]"; +const SINGLE_VIDEO_CARD_WIDTH_CLASS = "w-full max-w-[30rem]"; +const TWO_ITEM_GRID_HEIGHT_CLASS = "h-[11rem] sm:h-[13rem] md:h-[15rem]"; +const MOSAIC_GRID_HEIGHT_CLASS = "h-[13rem] sm:h-[16rem] md:h-[18rem]"; + const AttachmentMeta = ({ attachment }: { attachment: Attachment }) => { const { fileTypeLabel, fileSizeLabel } = getAttachmentMetadata(attachment); @@ -50,89 +62,175 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => { ); }; -const MotionItem = ({ +const getMotionPreviewProps = (item: VisualItem) => ({ + motionUrl: item.previewItem.kind === "motion" ? item.previewItem.motionUrl : item.sourceUrl, + presentationTimestampUs: item.previewItem.kind === "motion" ? item.previewItem.presentationTimestampUs : undefined, +}); + +const VisualTile = ({ + className, + onPreview, + overlayLabel, + children, +}: PropsWithChildren<{ className?: string; onPreview?: () => void; overlayLabel?: string }>) => { + return ( + + ); +}; + +const VideoPlayBadge = ({ className, children }: PropsWithChildren<{ className?: string }>) => ( + + {children} + +); + +const CollageVisualItem = ({ item, - featured = false, onPreview, + className, + overlayLabel, }: { - item: ReturnType[number]; - featured?: boolean; + item: VisualItem; onPreview?: () => void; + className?: string; + overlayLabel?: string; }) => { + const motionPreviewProps = item.kind === "motion" ? getMotionPreviewProps(item) : undefined; + return ( - + ); }; -const VisualGallery = ({ - items, - onPreview, -}: { - items: ReturnType; - onPreview?: (itemId: string) => void; -}) => { +const VisualGallery = ({ items, onPreview }: { items: VisualItem[]; onPreview?: (itemId: string) => void }) => { + if (items.length === 0) { + return null; + } + if (items.length === 1) { return ( -
- onPreview?.(items[0].id)} /> +
+ onPreview?.(items[0].id)} /> +
+ ); + } + + if (items.length === 2) { + return ( +
+ {items.map((item) => ( + onPreview?.(item.id)} /> + ))}
); } + if (items.length === 3) { + return ( +
+ onPreview?.(items[0].id)} /> + onPreview?.(items[1].id)} /> + onPreview?.(items[2].id)} /> +
+ ); + } + + const visibleItems = items.slice(0, 4); + const remainingCount = items.length - visibleItems.length; + return ( -
- {items.map((item) => ( - onPreview?.(item.id)} /> +
+ {visibleItems.map((item, index) => ( + 0 ? `+${remainingCount}` : undefined} + onPreview={() => onPreview?.(item.id)} + /> ))}
); }; -const AudioList = ({ attachments }: { attachments: Attachment[] }) => ( -
+const AudioList = ({ attachments, compact = false }: { attachments: Attachment[]; compact?: boolean }) => ( +
{attachments.map((attachment) => ( ( sourceUrl={getAttachmentUrl(attachment)} mimeType={attachment.type} size={Number(attachment.size)} + compact={compact} /> ))}
@@ -164,7 +263,7 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP const hasVisual = visualItems.length > 0; const hasAudio = audio.length > 0; const hasDocs = docs.length > 0; - const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length; + const hasMedia = hasVisual || hasAudio; if (attachments.length === 0) { return null; @@ -182,10 +281,13 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP count={visualItems.length + audio.length + docs.length} contentClassName="flex flex-col gap-2 p-2" > - {hasVisual && } - {hasVisual && sectionCount > 1 && } - {hasAudio && } - {hasAudio && hasDocs && } + {hasMedia && ( +
+ {hasVisual && } + {hasAudio && } +
+ )} + {hasMedia && hasDocs && } {hasDocs && } ); diff --git a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx index c3b801c72..adbd868b3 100644 --- a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx +++ b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx @@ -1,5 +1,6 @@ import { PauseIcon, PlayIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; import { formatFileSize, getFileTypeLabel } from "@/utils/format"; import { formatAudioTime } from "./attachmentHelpers"; @@ -56,9 +57,11 @@ interface AudioAttachmentItemProps { mimeType: string; size?: number; title?: string; + compact?: boolean; + className?: string; } -const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: AudioAttachmentItemProps) => { +const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title, compact = false, className }: AudioAttachmentItemProps) => { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); @@ -119,9 +122,9 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud }; return ( -
-
-
+
+
+
{displayTitle}
@@ -141,7 +144,7 @@ const AudioAttachmentItem = ({ filename, sourceUrl, mimeType, size, title }: Aud
-
+
-
{timeLabel}
- - +
+
{timeLabel}
+ + +