mirror of https://github.com/usememos/memos
refactor: simplify memo-metadata components
parent
d7284fe867
commit
be1b758d30
@ -0,0 +1,116 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon, FileIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { AttachmentItem } from "@/components/memo-metadata/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
|
||||
interface AttachmentItemCardProps {
|
||||
item: AttachmentItem;
|
||||
onRemove?: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
canMoveUp?: boolean;
|
||||
canMoveDown?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AttachmentItemCard: FC<AttachmentItemCardProps> = ({
|
||||
item,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
canMoveUp = true,
|
||||
canMoveDown = true,
|
||||
className,
|
||||
}) => {
|
||||
const { category, filename, thumbnailUrl, mimeType, size, isLocal } = item;
|
||||
const fileTypeLabel = getFileTypeLabel(mimeType);
|
||||
const fileSizeLabel = size ? formatFileSize(size) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded overflow-hidden bg-muted/40 flex items-center justify-center">
|
||||
{category === "image" && thumbnailUrl ? (
|
||||
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<FileIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col sm:flex-row sm:items-baseline gap-0.5 sm:gap-1.5">
|
||||
<span className="text-xs font-medium truncate" title={filename}>
|
||||
{filename}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted-foreground shrink-0">
|
||||
{isLocal && (
|
||||
<>
|
||||
<Loader2Icon className="w-2.5 h-2.5 animate-spin" />
|
||||
<span className="text-muted-foreground/50">•</span>
|
||||
</>
|
||||
)}
|
||||
<span>{fileTypeLabel}</span>
|
||||
{fileSizeLabel && (
|
||||
<>
|
||||
<span className="text-muted-foreground/50 hidden sm:inline">•</span>
|
||||
<span className="hidden sm:inline">{fileSizeLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex items-center gap-0.5">
|
||||
{onMoveUp && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={!canMoveUp}
|
||||
className={cn(
|
||||
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
|
||||
!canMoveUp && "opacity-20 cursor-not-allowed hover:bg-transparent",
|
||||
)}
|
||||
title="Move up"
|
||||
aria-label="Move attachment up"
|
||||
>
|
||||
<ChevronUpIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onMoveDown && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={!canMoveDown}
|
||||
className={cn(
|
||||
"p-0.5 rounded hover:bg-accent active:bg-accent transition-colors touch-manipulation",
|
||||
!canMoveDown && "opacity-20 cursor-not-allowed hover:bg-transparent",
|
||||
)}
|
||||
title="Move down"
|
||||
aria-label="Move attachment down"
|
||||
>
|
||||
<ChevronDownIcon className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors ml-0.5 touch-manipulation"
|
||||
title="Remove"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentItemCard;
|
||||
@ -0,0 +1,81 @@
|
||||
import { PaperclipIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { LocalFile } from "@/components/memo-metadata/types";
|
||||
import { toAttachmentItems } from "@/components/memo-metadata/types";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import AttachmentItemCard from "./AttachmentItemCard";
|
||||
|
||||
interface AttachmentListV2Props {
|
||||
attachments: Attachment[];
|
||||
localFiles?: LocalFile[];
|
||||
onAttachmentsChange?: (attachments: Attachment[]) => void;
|
||||
onRemoveLocalFile?: (previewUrl: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentListV2: FC<AttachmentListV2Props> = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => {
|
||||
if (attachments.length === 0 && localFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = toAttachmentItems(attachments, localFiles);
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0 || !onAttachmentsChange) return;
|
||||
|
||||
const newAttachments = [...attachments];
|
||||
[newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]];
|
||||
onAttachmentsChange(newAttachments);
|
||||
};
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === attachments.length - 1 || !onAttachmentsChange) return;
|
||||
|
||||
const newAttachments = [...attachments];
|
||||
[newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]];
|
||||
onAttachmentsChange(newAttachments);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (name: string) => {
|
||||
if (onAttachmentsChange) {
|
||||
onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (item: (typeof items)[0]) => {
|
||||
if (item.isLocal) {
|
||||
onRemoveLocalFile?.(item.id);
|
||||
} else {
|
||||
handleRemoveAttachment(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border bg-muted/30">
|
||||
<PaperclipIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">Attachments ({items.length})</span>
|
||||
</div>
|
||||
|
||||
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
|
||||
{items.map((item) => {
|
||||
const isLocalFile = item.isLocal;
|
||||
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
|
||||
|
||||
return (
|
||||
<AttachmentItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={() => handleRemoveItem(item)}
|
||||
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
|
||||
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
|
||||
canMoveUp={!isLocalFile && attachmentIndex > 0}
|
||||
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentListV2;
|
||||
@ -1,35 +1,30 @@
|
||||
import type { FC } from "react";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "@/components/memo-metadata";
|
||||
import { useEditorContext } from "../state";
|
||||
import type { EditorMetadataProps } from "../types";
|
||||
import AttachmentListV2 from "./AttachmentListV2";
|
||||
import LocationDisplayV2 from "./LocationDisplayV2";
|
||||
import RelationListV2 from "./RelationListV2";
|
||||
|
||||
export const EditorMetadata: FC<EditorMetadataProps> = () => {
|
||||
const { state, actions, dispatch } = useEditorContext();
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
{state.metadata.location && (
|
||||
<LocationDisplay
|
||||
mode="edit"
|
||||
location={state.metadata.location}
|
||||
onRemove={() => dispatch(actions.setMetadata({ location: undefined }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AttachmentList
|
||||
mode="edit"
|
||||
<AttachmentListV2
|
||||
attachments={state.metadata.attachments}
|
||||
localFiles={state.localFiles}
|
||||
onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))}
|
||||
onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))}
|
||||
/>
|
||||
|
||||
<RelationList
|
||||
mode="edit"
|
||||
<RelationListV2
|
||||
relations={state.metadata.relations}
|
||||
currentMemoName=""
|
||||
onRelationsChange={(relations) => dispatch(actions.setMetadata({ relations }))}
|
||||
/>
|
||||
|
||||
{state.metadata.location && (
|
||||
<LocationDisplayV2 location={state.metadata.location} onRemove={() => dispatch(actions.setMetadata({ location: undefined }))} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import { MapPinIcon, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
interface LocationDisplayV2Props {
|
||||
location: Location;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LocationDisplayV2: FC<LocationDisplayV2Props> = ({ location, onRemove, className }) => {
|
||||
const displayText = location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-border bg-background hover:bg-accent/20 transition-all w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<MapPinIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<span className="text-xs font-medium truncate" title={displayText}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 hidden sm:inline">
|
||||
{location.latitude.toFixed(4)}°, {location.longitude.toFixed(4)}°
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors touch-manipulation shrink-0 ml-auto"
|
||||
title="Remove"
|
||||
aria-label="Remove location"
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationDisplayV2;
|
||||
@ -0,0 +1,64 @@
|
||||
import { LinkIcon, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { extractMemoIdFromName } from "@/helpers/resource-names";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
interface RelationItemCardProps {
|
||||
memo: MemoRelation_Memo;
|
||||
onRemove?: () => void;
|
||||
parentPage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RelationItemCard: FC<RelationItemCardProps> = ({ memo, onRemove, parentPage, className }) => {
|
||||
const memoId = extractMemoIdFromName(memo.name);
|
||||
|
||||
if (onRemove) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-medium truncate flex-1" title={memo.snippet}>
|
||||
{memo.snippet}
|
||||
</span>
|
||||
|
||||
<div className="flex-shrink-0 flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="p-0.5 rounded hover:bg-destructive/10 active:bg-destructive/10 transition-colors touch-manipulation"
|
||||
title="Remove"
|
||||
aria-label="Remove relation"
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-1.5 py-1 rounded border border-transparent hover:border-border hover:bg-accent/20 transition-all",
|
||||
className,
|
||||
)}
|
||||
to={`/${memo.name}`}
|
||||
viewTransition
|
||||
state={{ from: parentPage }}
|
||||
>
|
||||
<span className="text-[10px] font-mono px-1 py-0.5 rounded bg-muted/50 text-muted-foreground shrink-0">{memoId.slice(0, 6)}</span>
|
||||
<span className="text-xs truncate flex-1" title={memo.snippet}>
|
||||
{memo.snippet}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelationItemCard;
|
||||
@ -0,0 +1,62 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import type { Memo, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import RelationItemCard from "./RelationItemCard";
|
||||
|
||||
interface RelationListV2Props {
|
||||
relations: MemoRelation[];
|
||||
onRelationsChange?: (relations: MemoRelation[]) => void;
|
||||
}
|
||||
|
||||
const RelationListV2: FC<RelationListV2Props> = ({ relations, onRelationsChange }) => {
|
||||
const [referencingMemos, setReferencingMemos] = useState<Memo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (relations.length > 0) {
|
||||
const requests = relations.map(async (relation) => {
|
||||
return await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
|
||||
});
|
||||
const list = await Promise.all(requests);
|
||||
setReferencingMemos(list);
|
||||
} else {
|
||||
setReferencingMemos([]);
|
||||
}
|
||||
})();
|
||||
}, [relations]);
|
||||
|
||||
const handleDeleteRelation = (memoName: string) => {
|
||||
if (onRelationsChange) {
|
||||
onRelationsChange(relations.filter((relation) => relation.relatedMemo?.name !== memoName));
|
||||
}
|
||||
};
|
||||
|
||||
if (referencingMemos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border bg-muted/30">
|
||||
<LinkIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">Relations ({referencingMemos.length})</span>
|
||||
</div>
|
||||
|
||||
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
|
||||
{referencingMemos.map((memo) => (
|
||||
<RelationItemCard
|
||||
key={memo.name}
|
||||
memo={create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet })}
|
||||
onRemove={() => handleDeleteRelation(memo.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelationListV2;
|
||||
@ -1,8 +1,13 @@
|
||||
// UI components for MemoEditor
|
||||
|
||||
export { default as AttachmentItemCard } from "./AttachmentItemCard";
|
||||
export { default as AttachmentListV2 } from "./AttachmentListV2";
|
||||
export * from "./EditorContent";
|
||||
export * from "./EditorMetadata";
|
||||
export * from "./EditorToolbar";
|
||||
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
|
||||
export { LinkMemoDialog } from "./LinkMemoDialog";
|
||||
export { LocationDialog } from "./LocationDialog";
|
||||
export { default as LocationDisplayV2 } from "./LocationDisplayV2";
|
||||
export { default as RelationItemCard } from "./RelationItemCard";
|
||||
export { default as RelationListV2 } from "./RelationListV2";
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SortableItem: React.FC<Props> = ({ id, className, children }: Props) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableItem;
|
||||
@ -0,0 +1,75 @@
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes < 0) return "Invalid size";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const size = bytes / Math.pow(k, i);
|
||||
const formatted = i === 0 ? size.toString() : size.toFixed(1);
|
||||
|
||||
return `${formatted} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function getFileTypeLabel(mimeType: string): string {
|
||||
if (!mimeType) return "File";
|
||||
|
||||
const [category, subtype] = mimeType.split("/");
|
||||
|
||||
const specialCases: Record<string, string> = {
|
||||
"application/pdf": "PDF",
|
||||
"application/zip": "ZIP",
|
||||
"application/x-zip-compressed": "ZIP",
|
||||
"application/json": "JSON",
|
||||
"application/xml": "XML",
|
||||
"text/plain": "TXT",
|
||||
"text/html": "HTML",
|
||||
"text/css": "CSS",
|
||||
"text/javascript": "JS",
|
||||
"application/javascript": "JS",
|
||||
};
|
||||
|
||||
if (specialCases[mimeType]) {
|
||||
return specialCases[mimeType];
|
||||
}
|
||||
|
||||
if (category === "image") {
|
||||
const imageTypes: Record<string, string> = {
|
||||
jpeg: "JPEG",
|
||||
jpg: "JPEG",
|
||||
png: "PNG",
|
||||
gif: "GIF",
|
||||
webp: "WebP",
|
||||
svg: "SVG",
|
||||
"svg+xml": "SVG",
|
||||
bmp: "BMP",
|
||||
ico: "ICO",
|
||||
};
|
||||
return imageTypes[subtype] || subtype.toUpperCase();
|
||||
}
|
||||
|
||||
if (category === "video") {
|
||||
const videoTypes: Record<string, string> = {
|
||||
mp4: "MP4",
|
||||
webm: "WebM",
|
||||
ogg: "OGG",
|
||||
avi: "AVI",
|
||||
mov: "MOV",
|
||||
quicktime: "MOV",
|
||||
};
|
||||
return videoTypes[subtype] || subtype.toUpperCase();
|
||||
}
|
||||
|
||||
if (category === "audio") {
|
||||
const audioTypes: Record<string, string> = {
|
||||
mp3: "MP3",
|
||||
mpeg: "MP3",
|
||||
wav: "WAV",
|
||||
ogg: "OGG",
|
||||
webm: "WebM",
|
||||
};
|
||||
return audioTypes[subtype] || subtype.toUpperCase();
|
||||
}
|
||||
|
||||
return subtype ? subtype.toUpperCase() : category.toUpperCase();
|
||||
}
|
||||
Loading…
Reference in New Issue