mirror of https://github.com/usememos/memos.git
feat(web): enhance file upload handling with local file support and preview
This commit is contained in:
parent
1d582e0f39
commit
72f93c5379
|
|
@ -3,6 +3,7 @@ import { uniqBy } from "lodash-es";
|
|||
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useContext, useState } from "react";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -13,8 +14,7 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { MemoEditorContext } from "../types";
|
||||
import { LinkMemoDialog } from "./InsertMenu/LinkMemoDialog";
|
||||
|
|
@ -37,8 +37,10 @@ const InsertMenu = observer((props: Props) => {
|
|||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
|
||||
const { fileInputRef, uploadingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((attachments: Attachment[]) => {
|
||||
context.setAttachmentList([...context.attachmentList, ...attachments]);
|
||||
const { fileInputRef, selectingFlag, handleFileInputChange, handleUploadClick } = useFileUpload((newFiles: LocalFile[]) => {
|
||||
if (context.addLocalFiles) {
|
||||
context.addLocalFiles(newFiles);
|
||||
}
|
||||
});
|
||||
|
||||
const linkMemo = useLinkMemo({
|
||||
|
|
@ -53,7 +55,7 @@ const InsertMenu = observer((props: Props) => {
|
|||
|
||||
const location = useLocation(props.location);
|
||||
|
||||
const isUploading = uploadingFlag || props.isUploading;
|
||||
const isUploading = selectingFlag || props.isUploading;
|
||||
|
||||
const handleLocationClick = () => {
|
||||
setLocationDialogOpen(true);
|
||||
|
|
|
|||
|
|
@ -1,43 +1,24 @@
|
|||
import mime from "mime";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { attachmentStore } from "@/store";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
|
||||
export const useFileUpload = (onUploadComplete: (attachments: Attachment[]) => void) => {
|
||||
export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadingFlag, setUploadingFlag] = useState(false);
|
||||
const [selectingFlag, setSelectingFlag] = useState(false);
|
||||
|
||||
const handleFileInputChange = async () => {
|
||||
if (!fileInputRef.current?.files || fileInputRef.current.files.length === 0 || uploadingFlag) {
|
||||
const handleFileInputChange = (event?: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(fileInputRef.current?.files || event?.target.files || []);
|
||||
if (files.length === 0 || selectingFlag) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingFlag(true);
|
||||
const createdAttachmentList: Attachment[] = [];
|
||||
|
||||
try {
|
||||
for (const file of fileInputRef.current.files) {
|
||||
const { name: filename, size, type } = file;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
filename,
|
||||
size,
|
||||
type: type || mime.getType(filename) || "text/plain",
|
||||
content: buffer,
|
||||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
createdAttachmentList.push(attachment);
|
||||
}
|
||||
onUploadComplete(createdAttachmentList);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
} finally {
|
||||
setUploadingFlag(false);
|
||||
}
|
||||
setSelectingFlag(true);
|
||||
const localFiles: LocalFile[] = files.map((file) => ({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
}));
|
||||
onFilesSelected(localFiles);
|
||||
setSelectingFlag(false);
|
||||
// Optionally clear input value to allow re-selecting the same file
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
|
|
@ -46,7 +27,7 @@ export const useFileUpload = (onUploadComplete: (attachments: Attachment[]) => v
|
|||
|
||||
return {
|
||||
fileInputRef,
|
||||
uploadingFlag,
|
||||
selectingFlag,
|
||||
handleFileInputChange,
|
||||
handleUploadClick,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t
|
|||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import DateTimeInput from "../DateTimeInput";
|
||||
import type { LocalFile } from "../memo-metadata";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||
import InsertMenu from "./ActionButton/InsertMenu";
|
||||
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||
|
|
@ -82,6 +83,14 @@ interface State {
|
|||
}
|
||||
|
||||
const MemoEditor = observer((props: Props) => {
|
||||
// Local files for preview and upload
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
|
||||
// Clean up blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl));
|
||||
};
|
||||
}, [localFiles]);
|
||||
const { className, cacheKey, memoName, parentMemoName, autoFocus, onConfirm, onCancel } = props;
|
||||
const t = useTranslate();
|
||||
const { i18n } = useTranslation();
|
||||
|
|
@ -252,6 +261,20 @@ const MemoEditor = observer((props: Props) => {
|
|||
}));
|
||||
};
|
||||
|
||||
// Add local files from InsertMenu
|
||||
const handleAddLocalFiles = (newFiles: LocalFile[]) => {
|
||||
setLocalFiles((prev) => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
// Remove a local file (e.g. on user remove)
|
||||
const handleRemoveLocalFile = (previewUrl: string) => {
|
||||
setLocalFiles((prev) => {
|
||||
const toRemove = prev.find((f) => f.previewUrl === previewUrl);
|
||||
if (toRemove) URL.revokeObjectURL(toRemove.previewUrl);
|
||||
return prev.filter((f) => f.previewUrl !== previewUrl);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetRelationList = (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
|
@ -259,69 +282,14 @@ const MemoEditor = observer((props: Props) => {
|
|||
}));
|
||||
};
|
||||
|
||||
const handleUploadResource = async (file: File) => {
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingAttachment: true,
|
||||
};
|
||||
});
|
||||
|
||||
const { name: filename, size, type } = file;
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
try {
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
filename,
|
||||
size,
|
||||
type,
|
||||
content: buffer,
|
||||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingAttachment: false,
|
||||
};
|
||||
});
|
||||
return attachment;
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isUploadingAttachment: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMultiFiles = async (files: FileList) => {
|
||||
const uploadedAttachmentList: Attachment[] = [];
|
||||
for (const file of files) {
|
||||
const attachment = await handleUploadResource(file);
|
||||
if (attachment) {
|
||||
uploadedAttachmentList.push(attachment);
|
||||
if (memoName) {
|
||||
await attachmentStore.updateAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
name: attachment.name,
|
||||
memo: memoName,
|
||||
}),
|
||||
updateMask: ["memo"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploadedAttachmentList.length > 0) {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
attachmentList: [...prevState.attachmentList, ...uploadedAttachmentList],
|
||||
}));
|
||||
}
|
||||
// Add files to local state for preview (no upload yet)
|
||||
const addFilesToLocal = (files: FileList | File[]) => {
|
||||
const fileArray = Array.from(files);
|
||||
const newLocalFiles: LocalFile[] = fileArray.map((file) => ({
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
}));
|
||||
setLocalFiles((prev) => [...prev, ...newLocalFiles]);
|
||||
};
|
||||
|
||||
const handleDropEvent = async (event: React.DragEvent) => {
|
||||
|
|
@ -332,7 +300,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
isDraggingFile: false,
|
||||
}));
|
||||
|
||||
await uploadMultiFiles(event.dataTransfer.files);
|
||||
addFilesToLocal(event.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -360,7 +328,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
const handlePasteEvent = async (event: React.ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
await uploadMultiFiles(event.clipboardData.files);
|
||||
addFilesToLocal(event.clipboardData.files);
|
||||
} else if (
|
||||
editorRef.current != null &&
|
||||
editorRef.current.getSelectedContent().length != 0 &&
|
||||
|
|
@ -385,15 +353,35 @@ const MemoEditor = observer((props: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
setState((state) => {
|
||||
return {
|
||||
...state,
|
||||
isRequesting: true,
|
||||
};
|
||||
});
|
||||
setState((state) => ({ ...state, isRequesting: true }));
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
try {
|
||||
// Update memo.
|
||||
// 1. Upload all local files and create attachments
|
||||
const newAttachments: Attachment[] = [];
|
||||
if (localFiles.length > 0) {
|
||||
setState((state) => ({ ...state, isUploadingAttachment: true }));
|
||||
try {
|
||||
for (const { file } of localFiles) {
|
||||
const buffer = new Uint8Array(await file.arrayBuffer());
|
||||
const attachment = await attachmentStore.createAttachment({
|
||||
attachment: Attachment.fromPartial({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
content: buffer,
|
||||
}),
|
||||
attachmentId: "",
|
||||
});
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
} finally {
|
||||
// Always reset upload state, even on error
|
||||
setState((state) => ({ ...state, isUploadingAttachment: false }));
|
||||
}
|
||||
}
|
||||
// 2. Update attachmentList with new attachments
|
||||
const allAttachments = [...state.attachmentList, ...newAttachments];
|
||||
// 3. Save memo (create or update)
|
||||
if (memoName) {
|
||||
const prevMemo = await memoStore.getOrFetchMemoByName(memoName);
|
||||
if (prevMemo) {
|
||||
|
|
@ -410,9 +398,9 @@ const MemoEditor = observer((props: Props) => {
|
|||
updateMask.add("visibility");
|
||||
memoPatch.visibility = state.memoVisibility;
|
||||
}
|
||||
if (!isEqual(state.attachmentList, prevMemo.attachments)) {
|
||||
if (!isEqual(allAttachments, prevMemo.attachments)) {
|
||||
updateMask.add("attachments");
|
||||
memoPatch.attachments = state.attachmentList;
|
||||
memoPatch.attachments = allAttachments;
|
||||
}
|
||||
if (!isEqual(state.relationList, prevMemo.relations)) {
|
||||
updateMask.add("relations");
|
||||
|
|
@ -452,7 +440,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
memo: Memo.fromPartial({
|
||||
content,
|
||||
visibility: state.memoVisibility,
|
||||
attachments: state.attachmentList,
|
||||
attachments: allAttachments,
|
||||
relations: state.relationList,
|
||||
location: state.location,
|
||||
}),
|
||||
|
|
@ -476,6 +464,9 @@ const MemoEditor = observer((props: Props) => {
|
|||
}
|
||||
}
|
||||
editorRef.current?.setContent("");
|
||||
// Clean up local files after successful save
|
||||
localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl));
|
||||
setLocalFiles([]);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.details);
|
||||
|
|
@ -494,14 +485,6 @@ const MemoEditor = observer((props: Props) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleCancelBtnClick = () => {
|
||||
localStorage.removeItem(contentCacheKey);
|
||||
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorFocus = () => {
|
||||
editorRef.current?.focus();
|
||||
};
|
||||
|
|
@ -518,19 +501,18 @@ const MemoEditor = observer((props: Props) => {
|
|||
[i18n.language, state.isFocusMode],
|
||||
);
|
||||
|
||||
const allowSave = (hasContent || state.attachmentList.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
|
||||
const allowSave =
|
||||
(hasContent || state.attachmentList.length > 0 || localFiles.length > 0) && !state.isUploadingAttachment && !state.isRequesting;
|
||||
|
||||
return (
|
||||
<MemoEditorContext.Provider
|
||||
value={{
|
||||
attachmentList: state.attachmentList,
|
||||
relationList: state.relationList,
|
||||
setAttachmentList: (attachmentList: Attachment[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
attachmentList,
|
||||
}));
|
||||
},
|
||||
setAttachmentList: handleSetAttachmentList,
|
||||
addLocalFiles: handleAddLocalFiles,
|
||||
removeLocalFile: handleRemoveLocalFile,
|
||||
localFiles,
|
||||
setRelationList: (relationList: MemoRelation[]) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
|
@ -584,7 +566,14 @@ const MemoEditor = observer((props: Props) => {
|
|||
}))
|
||||
}
|
||||
/>
|
||||
<AttachmentList mode="edit" attachments={state.attachmentList} onAttachmentsChange={handleSetAttachmentList} />
|
||||
{/* Show attachments and pending files together */}
|
||||
<AttachmentList
|
||||
mode="edit"
|
||||
attachments={state.attachmentList}
|
||||
onAttachmentsChange={handleSetAttachmentList}
|
||||
localFiles={localFiles}
|
||||
onRemoveLocalFile={handleRemoveLocalFile}
|
||||
/>
|
||||
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={handleSetRelationList} />
|
||||
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-row justify-start items-center gap-1">
|
||||
|
|
@ -604,7 +593,15 @@ const MemoEditor = observer((props: Props) => {
|
|||
<VisibilitySelector value={state.memoVisibility} onChange={(visibility) => handleMemoVisibilityChange(visibility)} />
|
||||
<div className="flex flex-row justify-end gap-1">
|
||||
{props.onCancel && (
|
||||
<Button variant="ghost" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={state.isRequesting}
|
||||
onClick={() => {
|
||||
localFiles.forEach(({ previewUrl }) => URL.revokeObjectURL(previewUrl));
|
||||
setLocalFiles([]);
|
||||
if (props.onCancel) props.onCancel();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createContext } from "react";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service";
|
||||
import type { LocalFile } from "../../memo-metadata";
|
||||
|
||||
interface Context {
|
||||
attachmentList: Attachment[];
|
||||
|
|
@ -8,6 +9,10 @@ interface Context {
|
|||
setAttachmentList: (attachmentList: Attachment[]) => void;
|
||||
setRelationList: (relationList: MemoRelation[]) => void;
|
||||
memoName?: string;
|
||||
// For local file upload/preview
|
||||
addLocalFiles?: (files: LocalFile[]) => void;
|
||||
removeLocalFile?: (previewUrl: string) => void;
|
||||
localFiles?: LocalFile[];
|
||||
}
|
||||
|
||||
export const MemoEditorContext = createContext<Context>({
|
||||
|
|
@ -15,4 +20,7 @@ export const MemoEditorContext = createContext<Context>({
|
|||
relationList: [],
|
||||
setAttachmentList: () => {},
|
||||
setRelationList: () => {},
|
||||
addLocalFiles: () => {},
|
||||
removeLocalFile: () => {},
|
||||
localFiles: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { FileIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
import { DisplayMode } from "./types";
|
||||
import type { AttachmentItem, DisplayMode } from "./types";
|
||||
|
||||
interface AttachmentCardProps {
|
||||
attachment: Attachment;
|
||||
/** Unified attachment item (uploaded or local file) */
|
||||
item: AttachmentItem;
|
||||
mode: DisplayMode;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
|
|
@ -14,32 +13,32 @@ interface AttachmentCardProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Shared attachment card component
|
||||
* Displays thumbnails for images in both modes, with size variations
|
||||
* Unified attachment card component for all file types
|
||||
* Renders differently based on mode (edit/view) and file category
|
||||
*/
|
||||
const AttachmentCard = ({ attachment, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => {
|
||||
const type = getAttachmentType(attachment);
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
const attachmentThumbnailUrl = getAttachmentThumbnailUrl(attachment);
|
||||
const isMedia = type === "image/*" || type === "video/*";
|
||||
const AttachmentCard = ({ item, mode, onRemove, onClick, className, showThumbnail = true }: AttachmentCardProps) => {
|
||||
const { category, filename, thumbnailUrl, sourceUrl } = item;
|
||||
const isMedia = category === "image" || category === "video";
|
||||
|
||||
// Editor mode - compact badge style with thumbnail
|
||||
// Editor mode - compact badge style with optional thumbnail
|
||||
if (mode === "edit") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-background text-secondary-foreground text-xs transition-colors hover:bg-accent",
|
||||
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border text-secondary-foreground text-xs transition-colors",
|
||||
"border-border bg-background hover:bg-accent",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showThumbnail && type === "image/*" ? (
|
||||
<img src={attachmentThumbnailUrl} alt={attachment.filename} className="w-5 h-5 shrink-0 object-cover rounded" />
|
||||
{showThumbnail && category === "image" && thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt={filename} className="w-5 h-5 shrink-0 object-cover rounded" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate max-w-[160px]">{attachment.filename}</span>
|
||||
<span className="truncate max-w-40">{filename}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -60,26 +59,27 @@ const AttachmentCard = ({ attachment, mode, onRemove, onClick, className, showTh
|
|||
);
|
||||
}
|
||||
|
||||
// View mode - media gets special treatment
|
||||
// View mode - specialized rendering for media
|
||||
if (isMedia) {
|
||||
if (type === "image/*") {
|
||||
if (category === "image") {
|
||||
return (
|
||||
<img
|
||||
className={cn("cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors", className)}
|
||||
src={attachmentThumbnailUrl}
|
||||
src={thumbnailUrl}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
// Fallback to source URL if thumbnail fails
|
||||
if (target.src.includes("?thumbnail=true")) {
|
||||
target.src = attachmentUrl;
|
||||
target.src = sourceUrl;
|
||||
}
|
||||
}}
|
||||
onClick={onClick}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
alt={attachment.filename}
|
||||
alt={filename}
|
||||
/>
|
||||
);
|
||||
} else if (type === "video/*") {
|
||||
} else if (category === "video") {
|
||||
return (
|
||||
<video
|
||||
className={cn(
|
||||
|
|
@ -88,7 +88,7 @@ const AttachmentCard = ({ attachment, mode, onRemove, onClick, className, showTh
|
|||
)}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
src={attachmentUrl}
|
||||
src={sourceUrl}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import { closestCenter, DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { closestCenter, DndContext, type DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } 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 type { 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";
|
||||
import type { AttachmentItem, BaseMetadataProps, LocalFile } from "./types";
|
||||
import { separateMediaAndDocs, toAttachmentItems } from "./types";
|
||||
|
||||
interface AttachmentListProps extends BaseMetadataProps {
|
||||
attachments: Attachment[];
|
||||
onAttachmentsChange?: (attachments: Attachment[]) => void;
|
||||
localFiles?: LocalFile[];
|
||||
onRemoveLocalFile?: (previewUrl: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -21,13 +24,14 @@ interface AttachmentListProps extends BaseMetadataProps {
|
|||
* - Shows all attachments as sortable badges with thumbnails
|
||||
* - Supports drag-and-drop reordering
|
||||
* - Shows remove buttons
|
||||
* - Shows pending files (not yet uploaded) with preview
|
||||
*
|
||||
* 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 AttachmentList = ({ attachments, mode, onAttachmentsChange, localFiles = [], onRemoveLocalFile }: AttachmentListProps) => {
|
||||
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
|
||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
||||
open: false,
|
||||
|
|
@ -59,26 +63,41 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange }: AttachmentLi
|
|||
setPreviewImage({ open: true, urls: imgUrls, index });
|
||||
};
|
||||
|
||||
// Editor mode: Show all attachments as sortable badges
|
||||
// Editor mode: Display all items as compact badges with drag-and-drop
|
||||
if (mode === "edit") {
|
||||
if (attachments.length === 0) {
|
||||
if (attachments.length === 0 && localFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = toAttachmentItems(attachments, localFiles);
|
||||
// Only uploaded attachments support reordering (stable server IDs)
|
||||
const sortableIds = attachments.map((a) => a.name);
|
||||
|
||||
const handleRemoveItem = (item: AttachmentItem) => {
|
||||
if (item.isLocal) {
|
||||
onRemoveLocalFile?.(item.id);
|
||||
} else {
|
||||
handleDeleteAttachment(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={attachments.map((attachment) => attachment.name)} strategy={verticalListSortingStrategy}>
|
||||
<SortableContext items={sortableIds} 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>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
{/* Uploaded items are wrapped in SortableItem for drag-and-drop */}
|
||||
{!item.isLocal ? (
|
||||
<SortableItem id={item.id} className="flex items-center gap-1.5 min-w-0">
|
||||
<AttachmentCard item={item} mode="edit" onRemove={() => handleRemoveItem(item)} showThumbnail />
|
||||
</SortableItem>
|
||||
) : (
|
||||
/* Local items render directly without sorting capability */
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<AttachmentCard item={item} mode="edit" onRemove={() => handleRemoveItem(item)} showThumbnail />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -87,32 +106,22 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange }: AttachmentLi
|
|||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
// View mode: Split items into media gallery and document list
|
||||
const items = toAttachmentItems(attachments, []);
|
||||
const { media: mediaItems, docs: docItems } = separateMediaAndDocs(items);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Media Gallery */}
|
||||
{mediaAttachments.length > 0 && (
|
||||
{mediaItems.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">
|
||||
{mediaItems.map((item) => (
|
||||
<div key={item.id} className="max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0">
|
||||
<AttachmentCard
|
||||
attachment={attachment}
|
||||
item={item}
|
||||
mode="view"
|
||||
onClick={() => {
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
handleImageClick(attachmentUrl, mediaAttachments);
|
||||
handleImageClick(item.sourceUrl, attachments);
|
||||
}}
|
||||
className="max-h-64 grow"
|
||||
/>
|
||||
|
|
@ -121,12 +130,14 @@ const AttachmentList = ({ attachments, mode, onAttachmentsChange }: AttachmentLi
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Files */}
|
||||
{otherAttachments.length > 0 && (
|
||||
{/* Document Files */}
|
||||
{docItems.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
|
||||
{otherAttachments.map((attachment) => (
|
||||
<MemoAttachment key={attachment.name} attachment={attachment} />
|
||||
))}
|
||||
{docItems.map((item) => {
|
||||
// Find original attachment for MemoAttachment component
|
||||
const attachment = attachments.find((a) => a.name === item.id);
|
||||
return attachment ? <MemoAttachment key={item.id} attachment={attachment} /> : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ export { default as RelationCard } from "./RelationCard";
|
|||
export { default as RelationList } from "./RelationList";
|
||||
|
||||
// Types
|
||||
export type { BaseMetadataProps, DisplayMode } from "./types";
|
||||
export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LocalFile } from "./types";
|
||||
export { attachmentToItem, fileToItem, filterByCategory, separateMediaAndDocs, toAttachmentItems } from "./types";
|
||||
|
|
|
|||
|
|
@ -2,9 +2,128 @@
|
|||
* Common types for memo metadata components
|
||||
*/
|
||||
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service";
|
||||
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
||||
|
||||
export type DisplayMode = "edit" | "view";
|
||||
|
||||
export interface BaseMetadataProps {
|
||||
mode: DisplayMode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File type categories for consistent handling across components
|
||||
*/
|
||||
export type FileCategory = "image" | "video" | "document";
|
||||
|
||||
/**
|
||||
* Pure view model for rendering attachments and local files
|
||||
* Contains only presentation data needed by UI components
|
||||
* Does not store references to original domain objects for cleaner architecture
|
||||
*/
|
||||
export interface AttachmentItem {
|
||||
/** Unique identifier - stable across renders */
|
||||
readonly id: string;
|
||||
/** Display name for the file */
|
||||
readonly filename: string;
|
||||
/** Categorized file type */
|
||||
readonly category: FileCategory;
|
||||
/** MIME type for detailed handling if needed */
|
||||
readonly mimeType: string;
|
||||
/** URL for thumbnail/preview display */
|
||||
readonly thumbnailUrl: string;
|
||||
/** URL for full file access */
|
||||
readonly sourceUrl: string;
|
||||
/** Size in bytes (optional) */
|
||||
readonly size?: number;
|
||||
/** Whether this represents a local file not yet uploaded */
|
||||
readonly isLocal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine file category from MIME type
|
||||
*/
|
||||
function categorizeFile(mimeType: string): FileCategory {
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
return "document";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an uploaded Attachment to AttachmentItem view model
|
||||
*/
|
||||
export function attachmentToItem(attachment: Attachment): AttachmentItem {
|
||||
const attachmentType = getAttachmentType(attachment);
|
||||
const sourceUrl = getAttachmentUrl(attachment);
|
||||
|
||||
return {
|
||||
id: attachment.name,
|
||||
filename: attachment.filename,
|
||||
category: categorizeFile(attachment.type),
|
||||
mimeType: attachment.type,
|
||||
thumbnailUrl: attachmentType === "image/*" ? getAttachmentThumbnailUrl(attachment) : sourceUrl,
|
||||
sourceUrl,
|
||||
size: attachment.size,
|
||||
isLocal: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a local File with blob URL to AttachmentItem view model
|
||||
*/
|
||||
export function fileToItem(file: File, blobUrl: string): AttachmentItem {
|
||||
return {
|
||||
id: blobUrl, // Use blob URL as unique ID since we don't have a server ID yet
|
||||
filename: file.name,
|
||||
category: categorizeFile(file.type),
|
||||
mimeType: file.type,
|
||||
thumbnailUrl: blobUrl,
|
||||
sourceUrl: blobUrl,
|
||||
size: file.size,
|
||||
isLocal: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple container for local files with their blob URLs
|
||||
* Kept minimal to avoid unnecessary abstraction
|
||||
*/
|
||||
export interface LocalFile {
|
||||
readonly file: File;
|
||||
readonly previewUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch convert attachments and local files to AttachmentItems
|
||||
* Returns items in order: uploaded first, then local
|
||||
*/
|
||||
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
|
||||
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items by category for specialized rendering
|
||||
*/
|
||||
export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] {
|
||||
const categorySet = new Set(categories);
|
||||
return items.filter((item) => categorySet.has(item.category));
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate items into media (image/video) and documents
|
||||
*/
|
||||
export function separateMediaAndDocs(items: AttachmentItem[]): { media: AttachmentItem[]; docs: AttachmentItem[] } {
|
||||
const media: AttachmentItem[] = [];
|
||||
const docs: AttachmentItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.category === "image" || item.category === "video") {
|
||||
media.push(item);
|
||||
} else {
|
||||
docs.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return { media, docs };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue