e.stopPropagation()}>
= (props: Props) => {
- const { location } = props;
- const [popoverOpen, setPopoverOpen] = useState(false);
-
- return (
-
-
-
-
-
- {location.placeholder ? location.placeholder : `[${location.latitude}, ${location.longitude}]`}
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default MemoLocationView;
diff --git a/web/src/components/MemoRelationListView.tsx b/web/src/components/MemoRelationListView.tsx
deleted file mode 100644
index 7699a1dbc..000000000
--- a/web/src/components/MemoRelationListView.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-import { LinkIcon, MilestoneIcon } from "lucide-react";
-import { memo, useState } from "react";
-import { Link } from "react-router-dom";
-import { cn } from "@/lib/utils";
-import { extractMemoIdFromName } from "@/store/common";
-import { Memo, MemoRelation } from "@/types/proto/api/v1/memo_service";
-import { useTranslate } from "@/utils/i18n";
-
-interface Props {
- memo: Memo;
- relations: MemoRelation[];
- parentPage?: string;
-}
-
-const MemoRelationListView = (props: Props) => {
- const t = useTranslate();
- const { memo, relations: relationList, parentPage } = props;
- const referencingMemoList = relationList
- .filter((relation) => relation.memo?.name === memo.name && relation.relatedMemo?.name !== memo.name)
- .map((relation) => relation.relatedMemo!);
- const referencedMemoList = relationList
- .filter((relation) => relation.memo?.name !== memo.name && relation.relatedMemo?.name === memo.name)
- .map((relation) => relation.memo!);
- const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">(
- referencingMemoList.length === 0 ? "referenced" : "referencing",
- );
-
- if (referencingMemoList.length + referencedMemoList.length === 0) {
- return null;
- }
-
- return (
-
-
- {referencingMemoList.length > 0 && (
-
- )}
- {referencedMemoList.length > 0 && (
-
- )}
-
- {selectedTab === "referencing" && referencingMemoList.length > 0 && (
-
- {referencingMemoList.map((memo) => {
- return (
-
-
- {extractMemoIdFromName(memo.name).slice(0, 6)}
-
- {memo.snippet}
-
- );
- })}
-
- )}
- {selectedTab === "referenced" && referencedMemoList.length > 0 && (
-
- {referencedMemoList.map((memo) => {
- return (
-
-
- {extractMemoIdFromName(memo.name).slice(0, 6)}
-
- {memo.snippet}
-
- );
- })}
-
- )}
-
- );
-};
-
-export default memo(MemoRelationListView);
diff --git a/web/src/components/MemoView.tsx b/web/src/components/MemoView.tsx
index 8d0384059..3040084b8 100644
--- a/web/src/components/MemoView.tsx
+++ b/web/src/components/MemoView.tsx
@@ -14,16 +14,14 @@ import { useTranslate } from "@/utils/i18n";
import { convertVisibilityToString } from "@/utils/memo";
import { isSuperUser } from "@/utils/user";
import MemoActionMenu from "./MemoActionMenu";
-import MemoAttachmentListView from "./MemoAttachmentListView";
import MemoContent from "./MemoContent";
import MemoEditor from "./MemoEditor";
-import MemoLocationView from "./MemoLocationView";
import MemoReactionistView from "./MemoReactionListView";
-import MemoRelationListView from "./MemoRelationListView";
import PreviewImageDialog from "./PreviewImageDialog";
import ReactionSelector from "./ReactionSelector";
import UserAvatar from "./UserAvatar";
import VisibilityIcon from "./VisibilityIcon";
+import { LocationDisplay, AttachmentList, RelationList } from "./memo-metadata";
interface Props {
memo: Memo;
@@ -256,9 +254,9 @@ const MemoView: React.FC = observer((props: Props) => {
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
parentPage={parentPage}
/>
- {memo.location && }
-
-
+ {memo.location && }
+
+
{nsfw && !showNSFWContent && (
diff --git a/web/src/components/memo-metadata/AttachmentCard.tsx b/web/src/components/memo-metadata/AttachmentCard.tsx
new file mode 100644
index 000000000..4b60f03ee
--- /dev/null
+++ b/web/src/components/memo-metadata/AttachmentCard.tsx
@@ -0,0 +1,96 @@
+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";
+
+interface AttachmentCardProps {
+ attachment: Attachment;
+ mode: DisplayMode;
+ onRemove?: () => void;
+ onClick?: () => void;
+ className?: string;
+ showThumbnail?: boolean;
+}
+
+/**
+ * Shared attachment card component
+ * Displays thumbnails for images in both modes, with size variations
+ */
+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/*";
+
+ // Editor mode - compact badge style with thumbnail
+ if (mode === "edit") {
+ return (
+
+ {showThumbnail && type === "image/*" ? (
+

+ ) : (
+
+ )}
+
{attachment.filename}
+ {onRemove && (
+
+ )}
+
+ );
+ }
+
+ // View mode - media gets special treatment
+ if (isMedia) {
+ if (type === "image/*") {
+ return (
+

{
+ const target = e.target as HTMLImageElement;
+ if (target.src.includes("?thumbnail=true")) {
+ target.src = attachmentUrl;
+ }
+ }}
+ onClick={onClick}
+ decoding="async"
+ loading="lazy"
+ alt={attachment.filename}
+ />
+ );
+ } else if (type === "video/*") {
+ return (
+
+ );
+ }
+ }
+
+ // View mode - non-media files (will be handled by parent component for proper file card display)
+ return null;
+};
+
+export default AttachmentCard;
diff --git a/web/src/components/memo-metadata/AttachmentList.tsx b/web/src/components/memo-metadata/AttachmentList.tsx
new file mode 100644
index 000000000..bf1010fae
--- /dev/null
+++ b/web/src/components/memo-metadata/AttachmentList.tsx
@@ -0,0 +1,144 @@
+import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } 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 { 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";
+
+interface AttachmentListProps extends BaseMetadataProps {
+ attachments: Attachment[];
+ onAttachmentsChange?: (attachments: Attachment[]) => void;
+}
+
+/**
+ * Unified AttachmentList component for both editor and view modes
+ *
+ * Editor mode:
+ * - Shows all attachments as sortable badges with thumbnails
+ * - Supports drag-and-drop reordering
+ * - Shows remove buttons
+ *
+ * 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 sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
+ const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
+ open: false,
+ urls: [],
+ index: 0,
+ });
+
+ const handleDeleteAttachment = (name: string) => {
+ if (onAttachmentsChange) {
+ onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name));
+ }
+ };
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (over && active.id !== over.id && onAttachmentsChange) {
+ const oldIndex = attachments.findIndex((attachment) => attachment.name === active.id);
+ const newIndex = attachments.findIndex((attachment) => attachment.name === over.id);
+ onAttachmentsChange(arrayMove(attachments, oldIndex, newIndex));
+ }
+ };
+
+ const handleImageClick = (imgUrl: string, mediaAttachments: Attachment[]) => {
+ const imgUrls = mediaAttachments
+ .filter((attachment) => getAttachmentType(attachment) === "image/*")
+ .map((attachment) => getAttachmentUrl(attachment));
+ const index = imgUrls.findIndex((url) => url === imgUrl);
+ setPreviewImage({ open: true, urls: imgUrls, index });
+ };
+
+ // Editor mode: Show all attachments as sortable badges
+ if (mode === "edit") {
+ if (attachments.length === 0) {
+ return null;
+ }
+
+ return (
+
+ attachment.name)} strategy={verticalListSortingStrategy}>
+
+ {attachments.map((attachment) => (
+
+
+ handleDeleteAttachment(attachment.name)}
+ showThumbnail={true}
+ />
+
+
+ ))}
+
+
+
+ );
+ }
+
+ // 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);
+ }
+ });
+
+ return (
+ <>
+ {/* Media Gallery */}
+ {mediaAttachments.length > 0 && (
+
+ {mediaAttachments.map((attachment) => (
+
+
{
+ const attachmentUrl = getAttachmentUrl(attachment);
+ handleImageClick(attachmentUrl, mediaAttachments);
+ }}
+ className="max-h-64 grow"
+ />
+
+ ))}
+
+ )}
+
+ {/* Other Files */}
+ {otherAttachments.length > 0 && (
+
+ {otherAttachments.map((attachment) => (
+
+ ))}
+
+ )}
+
+ {/* Image Preview Dialog */}
+
setPreviewImage((prev) => ({ ...prev, open }))}
+ imgUrls={previewImage.urls}
+ initialIndex={previewImage.index}
+ />
+ >
+ );
+};
+
+export default AttachmentList;
diff --git a/web/src/components/memo-metadata/LocationDisplay.tsx b/web/src/components/memo-metadata/LocationDisplay.tsx
new file mode 100644
index 000000000..1c57f508a
--- /dev/null
+++ b/web/src/components/memo-metadata/LocationDisplay.tsx
@@ -0,0 +1,62 @@
+import { LatLng } from "leaflet";
+import { ExternalLinkIcon, MapPinIcon } from "lucide-react";
+import { useState } from "react";
+import { Location } from "@/types/proto/api/v1/memo_service";
+import LeafletMap from "../LeafletMap";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import MetadataBadge from "./MetadataBadge";
+import { BaseMetadataProps } from "./types";
+
+interface LocationDisplayProps extends BaseMetadataProps {
+ location?: Location;
+ onRemove?: () => void;
+ onClick?: () => void;
+}
+
+/**
+ * Unified Location component for both editor and view modes
+ *
+ * Editor mode: Shows badge with remove button
+ * View mode: Shows badge with popover map on click
+ */
+const LocationDisplay = ({ location, mode, onRemove, onClick, className }: LocationDisplayProps) => {
+ const [popoverOpen, setPopoverOpen] = useState(false);
+
+ if (!location) {
+ return null;
+ }
+
+ const displayText = location.placeholder || `[${location.latitude}, ${location.longitude}]`;
+
+ // Editor mode: Simple badge with remove button
+ if (mode === "edit") {
+ return (
+
+ } onRemove={onRemove} onClick={onClick} className={className}>
+ {displayText}
+
+
+ );
+ }
+
+ // View mode: Badge with popover map
+ return (
+
+
+
+ } onClick={() => setPopoverOpen(true)} className={className}>
+ {displayText}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default LocationDisplay;
diff --git a/web/src/components/memo-metadata/MetadataBadge.tsx b/web/src/components/memo-metadata/MetadataBadge.tsx
new file mode 100644
index 000000000..2ce092af2
--- /dev/null
+++ b/web/src/components/memo-metadata/MetadataBadge.tsx
@@ -0,0 +1,46 @@
+import { XIcon } from "lucide-react";
+import { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+interface MetadataBadgeProps {
+ icon: ReactNode;
+ children: ReactNode;
+ onRemove?: () => void;
+ onClick?: () => void;
+ className?: string;
+ maxWidth?: string;
+}
+
+/**
+ * Shared badge component for metadata display (Location, Tags, etc.)
+ * Provides consistent styling across editor and view modes
+ */
+const MetadataBadge = ({ icon, children, onRemove, onClick, className, maxWidth = "max-w-[160px]" }: MetadataBadgeProps) => {
+ return (
+
+ {icon}
+ {children}
+ {onRemove && (
+
+ )}
+
+ );
+};
+
+export default MetadataBadge;
diff --git a/web/src/components/memo-metadata/MetadataCard.tsx b/web/src/components/memo-metadata/MetadataCard.tsx
new file mode 100644
index 000000000..3c66d2dd3
--- /dev/null
+++ b/web/src/components/memo-metadata/MetadataCard.tsx
@@ -0,0 +1,26 @@
+import { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+interface MetadataCardProps {
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * Shared card component for structured metadata (Relations, Comments, etc.)
+ * Provides consistent card styling across editor and view modes
+ */
+const MetadataCard = ({ children, className }: MetadataCardProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default MetadataCard;
diff --git a/web/src/components/memo-metadata/RelationCard.tsx b/web/src/components/memo-metadata/RelationCard.tsx
new file mode 100644
index 000000000..9ae34dafb
--- /dev/null
+++ b/web/src/components/memo-metadata/RelationCard.tsx
@@ -0,0 +1,61 @@
+import { LinkIcon, XIcon } from "lucide-react";
+import { Link } from "react-router-dom";
+import { cn } from "@/lib/utils";
+import { extractMemoIdFromName } from "@/store/common";
+import { MemoRelation_Memo } from "@/types/proto/api/v1/memo_service";
+import { DisplayMode } from "./types";
+
+interface RelationCardProps {
+ memo: MemoRelation_Memo;
+ mode: DisplayMode;
+ onRemove?: () => void;
+ parentPage?: string;
+ className?: string;
+}
+
+/**
+ * Shared relation card component for displaying linked memos
+ *
+ * Editor mode: Badge with remove button, click to remove
+ * View mode: Link with memo ID and snippet, click to navigate
+ */
+const RelationCard = ({ memo, mode, onRemove, parentPage, className }: RelationCardProps) => {
+ const memoId = extractMemoIdFromName(memo.name);
+
+ // Editor mode: Badge with remove
+ if (mode === "edit") {
+ return (
+
+
+ {memo.snippet}
+
+
+ );
+ }
+
+ // View mode: Navigable link with ID and snippet
+ return (
+
+ {memoId.slice(0, 6)}
+ {memo.snippet}
+
+ );
+};
+
+export default RelationCard;
diff --git a/web/src/components/memo-metadata/RelationList.tsx b/web/src/components/memo-metadata/RelationList.tsx
new file mode 100644
index 000000000..85ff0f4da
--- /dev/null
+++ b/web/src/components/memo-metadata/RelationList.tsx
@@ -0,0 +1,153 @@
+import { LinkIcon, MilestoneIcon } from "lucide-react";
+import { observer } from "mobx-react-lite";
+import { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
+import { memoStore } from "@/store";
+import { Memo, MemoRelation, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
+import { useTranslate } from "@/utils/i18n";
+import MetadataCard from "./MetadataCard";
+import RelationCard from "./RelationCard";
+import { BaseMetadataProps } from "./types";
+
+interface RelationListProps extends BaseMetadataProps {
+ relations: MemoRelation[];
+ currentMemoName?: string;
+ onRelationsChange?: (relations: MemoRelation[]) => void;
+ parentPage?: string;
+}
+
+/**
+ * Unified RelationList component for both editor and view modes
+ *
+ * Editor mode:
+ * - Shows only outgoing relations (referencing)
+ * - Badge-style display with remove buttons
+ * - Compact inline layout
+ *
+ * View mode:
+ * - Shows bidirectional relations in tabbed card
+ * - "Referencing" tab: Memos this memo links to
+ * - "Referenced by" tab: Memos that link to this memo
+ * - Navigable links with memo IDs
+ */
+const RelationList = observer(({ relations, currentMemoName, mode, onRelationsChange, parentPage, className }: RelationListProps) => {
+ const t = useTranslate();
+ const [referencingMemos, setReferencingMemos] = useState([]);
+ const [selectedTab, setSelectedTab] = useState<"referencing" | "referenced">("referencing");
+
+ // Get referencing and referenced relations
+ const referencingRelations = relations.filter(
+ (relation) =>
+ relation.type === MemoRelation_Type.REFERENCE &&
+ (mode === "edit" || relation.memo?.name === currentMemoName) &&
+ relation.relatedMemo?.name !== currentMemoName,
+ );
+
+ const referencedRelations = relations.filter(
+ (relation) =>
+ relation.type === MemoRelation_Type.REFERENCE &&
+ relation.memo?.name !== currentMemoName &&
+ relation.relatedMemo?.name === currentMemoName,
+ );
+
+ // Fetch full memo details for editor mode
+ useEffect(() => {
+ if (mode === "edit" && referencingRelations.length > 0) {
+ (async () => {
+ const requests = referencingRelations.map(async (relation) => {
+ return await memoStore.getOrFetchMemoByName(relation.relatedMemo!.name, { skipStore: true });
+ });
+ const list = await Promise.all(requests);
+ setReferencingMemos(list);
+ })();
+ }
+ }, [mode, relations]);
+
+ const handleDeleteRelation = (memoName: string) => {
+ if (onRelationsChange) {
+ onRelationsChange(relations.filter((relation) => relation.relatedMemo?.name !== memoName));
+ }
+ };
+
+ // Editor mode: Simple badge list
+ if (mode === "edit") {
+ if (referencingMemos.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {referencingMemos.map((memo) => (
+ handleDeleteRelation(memo.name)}
+ />
+ ))}
+
+ );
+ }
+
+ // View mode: Tabbed card with bidirectional relations
+ if (referencingRelations.length === 0 && referencedRelations.length === 0) {
+ return null;
+ }
+
+ // Auto-select tab based on which has content
+ const activeTab = referencingRelations.length === 0 ? "referenced" : selectedTab;
+
+ return (
+
+ {/* Tabs */}
+
+ {referencingRelations.length > 0 && (
+
+ )}
+ {referencedRelations.length > 0 && (
+
+ )}
+
+
+ {/* Referencing List */}
+ {activeTab === "referencing" && referencingRelations.length > 0 && (
+
+ {referencingRelations.map((relation) => (
+
+ ))}
+
+ )}
+
+ {/* Referenced List */}
+ {activeTab === "referenced" && referencedRelations.length > 0 && (
+
+ {referencedRelations.map((relation) => (
+
+ ))}
+
+ )}
+
+ );
+});
+
+export default RelationList;
diff --git a/web/src/components/memo-metadata/index.ts b/web/src/components/memo-metadata/index.ts
new file mode 100644
index 000000000..78fa92ba7
--- /dev/null
+++ b/web/src/components/memo-metadata/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Unified memo metadata components
+ * Provides consistent styling and behavior across editor and view modes
+ */
+
+export { default as LocationDisplay } from "./LocationDisplay";
+export { default as AttachmentList } from "./AttachmentList";
+export { default as RelationList } from "./RelationList";
+
+// Base components (can be used for other metadata types)
+export { default as MetadataBadge } from "./MetadataBadge";
+export { default as MetadataCard } from "./MetadataCard";
+export { default as AttachmentCard } from "./AttachmentCard";
+export { default as RelationCard } from "./RelationCard";
+
+// Types
+export type { DisplayMode, BaseMetadataProps } from "./types";
diff --git a/web/src/components/memo-metadata/types.ts b/web/src/components/memo-metadata/types.ts
new file mode 100644
index 000000000..34f2214a4
--- /dev/null
+++ b/web/src/components/memo-metadata/types.ts
@@ -0,0 +1,10 @@
+/**
+ * Common types for memo metadata components
+ */
+
+export type DisplayMode = "edit" | "view";
+
+export interface BaseMetadataProps {
+ mode: DisplayMode;
+ className?: string;
+}