feat(web): enhance file upload handling with local file support and preview

This commit is contained in:
Steven 2025-11-24 21:31:18 +08:00
parent 1d582e0f39
commit 72f93c5379
8 changed files with 317 additions and 198 deletions

View File

@ -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);

View File

@ -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,
};

View File

@ -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>
)}

View File

@ -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: [],
});

View File

@ -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
/>
);

View File

@ -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>
)}

View File

@ -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";

View File

@ -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 };
}