diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 63da079c..baac1084 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 7d867510..ae69db2b 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 37f96e00..735d4b0e 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 = ({ - item, - featured = false, +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, -}: { - item: ReturnType[number]; - featured?: boolean; - onPreview?: () => void; -}) => { + overlayLabel, + children, +}: PropsWithChildren<{ className?: string; onPreview?: () => void; overlayLabel?: string }>) => { return ( - ); }; -const VisualGallery = ({ - items, +const VideoPlayBadge = ({ className, children }: PropsWithChildren<{ className?: string }>) => ( + + {children} + +); + +const CollageVisualItem = ({ + item, onPreview, + className, + overlayLabel, }: { - items: ReturnType; - onPreview?: (itemId: string) => void; + item: VisualItem; + onPreview?: () => void; + className?: string; + overlayLabel?: string; }) => { - if (items.length === 1) { + const motionPreviewProps = item.kind === "motion" ? getMotionPreviewProps(item) : undefined; + + return ( + + {item.kind === "video" ? ( + <> + + ); +}; + +const SingleVisualItem = ({ item, onPreview }: { item: VisualItem; onPreview?: () => void }) => { + const motionPreviewProps = item.kind === "motion" ? getMotionPreviewProps(item) : undefined; + + if (item.kind === "image") { return ( -
- onPreview?.(items[0].id)} /> -
+ + {item.filename} + + ); + } + + if (item.kind === "motion" && motionPreviewProps) { + return ( + + + ); } return ( -
- {items.map((item) => ( - onPreview?.(item.id)} /> + +
+