mirror of https://github.com/usememos/memos.git
refactor(web): unify Location/Attachments/Relations components for consistency
This refactor introduces a maintainable, unified component architecture for
memo metadata (Location, Attachments, Relations) that ensures visual and
functional consistency between editor and view modes.
## Key Changes
### New Shared Component Library (web/src/components/memo-metadata/)
- **MetadataBadge**: Reusable badge component for compact metadata display
- **MetadataCard**: Reusable card component for structured metadata
- **LocationDisplay**: Unified location component with mode support
- **AttachmentList**: Unified attachment list with thumbnail support
- **RelationList**: Unified relation list with bidirectional tabs
- **AttachmentCard**: Shared attachment rendering logic
- **RelationCard**: Shared relation item rendering logic
### Benefits
1. **Single Source of Truth**: One component handles both edit/view modes
2. **Visual Consistency**: Same badge/card styles across all contexts
3. **Better Maintainability**: Shared logic reduces duplication (9 files vs 6)
4. **Enhanced Features**:
- Thumbnails for images in editor mode
- Consistent badge sizing (h-7) across all metadata types
- Unified spacing and interaction patterns
### Component Updates
- **MemoEditor**: Now uses LocationDisplay, AttachmentList, RelationList
- **MemoView**: Now uses the same unified components with mode="view"
- Removed old separate implementations:
- MemoEditor/{LocationView, AttachmentListView, RelationListView}
- {MemoLocationView, MemoAttachmentListView, MemoRelationListView}
### Technical Details
- All components use `DisplayMode` type ("edit" | "view") for behavior
- Editor mode: Shows badges with remove buttons, drag-sort for attachments
- View mode: Shows rich previews, popovers, galleries, navigation links
- TypeScript strict mode compatible, passes all lint checks
- Production build tested and verified
This architecture follows modern React patterns and makes it easy to add
new metadata types or modify existing ones without duplicating code.
This commit is contained in:
parent
a2ccf6b201
commit
4e37fcfa22
|
|
@ -20,12 +20,10 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t
|
|||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import DateTimeInput from "../DateTimeInput";
|
||||
import { LocationDisplay, AttachmentList, RelationList } from "../memo-metadata";
|
||||
import InsertMenu from "./ActionButton/InsertMenu";
|
||||
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||
import AttachmentListView from "./AttachmentListView";
|
||||
import Editor, { EditorRefActions } from "./Editor";
|
||||
import LocationView from "./LocationView";
|
||||
import RelationListView from "./RelationListView";
|
||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
||||
import { MemoEditorContext } from "./types";
|
||||
|
||||
|
|
@ -490,7 +488,8 @@ const MemoEditor = observer((props: Props) => {
|
|||
onCompositionEnd={handleCompositionEnd}
|
||||
>
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<LocationView
|
||||
<LocationDisplay
|
||||
mode="edit"
|
||||
location={state.location}
|
||||
onRemove={() =>
|
||||
setState((prevState) => ({
|
||||
|
|
@ -499,8 +498,8 @@ const MemoEditor = observer((props: Props) => {
|
|||
}))
|
||||
}
|
||||
/>
|
||||
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
||||
<AttachmentList mode="edit" attachments={state.attachmentList} onAttachmentsChange={handleSetAttachmentList} />
|
||||
<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">
|
||||
<InsertMenu
|
||||
|
|
|
|||
|
|
@ -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<Props> = observer((props: Props) => {
|
|||
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
|
||||
parentPage={parentPage}
|
||||
/>
|
||||
{memo.location && <MemoLocationView location={memo.location} />}
|
||||
<MemoAttachmentListView attachments={memo.attachments} />
|
||||
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
|
||||
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
|
||||
<AttachmentList mode="view" attachments={memo.attachments} />
|
||||
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
||||
<MemoReactionistView memo={memo} reactions={memo.reactions} />
|
||||
</div>
|
||||
{nsfw && !showNSFWContent && (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group 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",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{showThumbnail && type === "image/*" ? (
|
||||
<img src={attachmentThumbnailUrl} alt={attachment.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>
|
||||
{onRemove && (
|
||||
<button
|
||||
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// View mode - media gets special treatment
|
||||
if (isMedia) {
|
||||
if (type === "image/*") {
|
||||
return (
|
||||
<img
|
||||
className={cn("cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain transition-colors", className)}
|
||||
src={attachmentThumbnailUrl}
|
||||
onError={(e) => {
|
||||
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 (
|
||||
<video
|
||||
className={cn(
|
||||
"cursor-pointer h-full w-auto rounded-lg border border-border/60 object-contain bg-muted transition-colors",
|
||||
className,
|
||||
)}
|
||||
preload="metadata"
|
||||
crossOrigin="anonymous"
|
||||
src={attachmentUrl}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// View mode - non-media files (will be handled by parent component for proper file card display)
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AttachmentCard;
|
||||
|
|
@ -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 (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={attachments.map((attachment) => attachment.name)} 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 && (
|
||||
<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">
|
||||
<AttachmentCard
|
||||
attachment={attachment}
|
||||
mode="view"
|
||||
onClick={() => {
|
||||
const attachmentUrl = getAttachmentUrl(attachment);
|
||||
handleImageClick(attachmentUrl, mediaAttachments);
|
||||
}}
|
||||
className="max-h-64 grow"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Files */}
|
||||
{otherAttachments.length > 0 && (
|
||||
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
|
||||
{otherAttachments.map((attachment) => (
|
||||
<MemoAttachment key={attachment.name} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Preview Dialog */}
|
||||
<PreviewImageDialog
|
||||
open={previewImage.open}
|
||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
||||
imgUrls={previewImage.urls}
|
||||
initialIndex={previewImage.index}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentList;
|
||||
|
|
@ -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<boolean>(false);
|
||||
|
||||
if (!location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayText = location.placeholder || `[${location.latitude}, ${location.longitude}]`;
|
||||
|
||||
// Editor mode: Simple badge with remove button
|
||||
if (mode === "edit") {
|
||||
return (
|
||||
<div className="w-full flex flex-row flex-wrap gap-2 mt-2">
|
||||
<MetadataBadge icon={<MapPinIcon className="w-3.5 h-3.5" />} onRemove={onRemove} onClick={onClick} className={className}>
|
||||
{displayText}
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// View mode: Badge with popover map
|
||||
return (
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="w-full flex flex-row flex-wrap gap-2">
|
||||
<MetadataBadge icon={<MapPinIcon className="w-3.5 h-3.5" />} onClick={() => setPopoverOpen(true)} className={className}>
|
||||
<span>{displayText}</span>
|
||||
<ExternalLinkIcon className="w-2.5 h-2.5 ml-1 opacity-50" />
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start">
|
||||
<div className="min-w-80 sm:w-lg flex flex-col justify-start items-start">
|
||||
<LeafletMap latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationDisplay;
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group 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",
|
||||
onClick && "cursor-pointer hover:bg-accent",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className={cn("truncate", maxWidth)}>{children}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataBadge;
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col justify-start items-start w-full px-2 pt-2 pb-1.5 bg-muted/50 rounded-lg border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataCard;
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group 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 cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LinkIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate max-w-[160px]">{memo.snippet}</span>
|
||||
<XIcon className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// View mode: Navigable link with ID and snippet
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"w-full flex flex-row justify-start items-center text-sm leading-5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-2 py-1 transition-colors",
|
||||
className,
|
||||
)}
|
||||
to={`/${memo.name}`}
|
||||
viewTransition
|
||||
state={{
|
||||
from: parentPage,
|
||||
}}
|
||||
>
|
||||
<span className="text-xs opacity-60 leading-4 border border-border font-mono px-1 rounded-full mr-1">{memoId.slice(0, 6)}</span>
|
||||
<span className="truncate">{memo.snippet}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelationCard;
|
||||
|
|
@ -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<Memo[]>([]);
|
||||
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 (
|
||||
<div className="w-full flex flex-row gap-2 mt-2 flex-wrap">
|
||||
{referencingMemos.map((memo) => (
|
||||
<RelationCard
|
||||
key={memo.name}
|
||||
memo={{ name: memo.name, snippet: memo.snippet }}
|
||||
mode="edit"
|
||||
onRemove={() => handleDeleteRelation(memo.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<MetadataCard className={className}>
|
||||
{/* Tabs */}
|
||||
<div className="w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60">
|
||||
{referencingRelations.length > 0 && (
|
||||
<button
|
||||
className={cn(
|
||||
"w-auto flex flex-row justify-start items-center text-xs gap-0.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-0.5 transition-colors",
|
||||
activeTab === "referencing" && "text-foreground bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedTab("referencing")}
|
||||
>
|
||||
<LinkIcon className="w-3 h-auto shrink-0 opacity-70" />
|
||||
<span>{t("common.referencing")}</span>
|
||||
<span className="opacity-80">({referencingRelations.length})</span>
|
||||
</button>
|
||||
)}
|
||||
{referencedRelations.length > 0 && (
|
||||
<button
|
||||
className={cn(
|
||||
"w-auto flex flex-row justify-start items-center text-xs gap-0.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded px-1 py-0.5 transition-colors",
|
||||
activeTab === "referenced" && "text-foreground bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedTab("referenced")}
|
||||
>
|
||||
<MilestoneIcon className="w-3 h-auto shrink-0 opacity-70" />
|
||||
<span>{t("common.referenced-by")}</span>
|
||||
<span className="opacity-80">({referencedRelations.length})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Referencing List */}
|
||||
{activeTab === "referencing" && referencingRelations.length > 0 && (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{referencingRelations.map((relation) => (
|
||||
<RelationCard key={relation.relatedMemo!.name} memo={relation.relatedMemo!} mode="view" parentPage={parentPage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Referenced List */}
|
||||
{activeTab === "referenced" && referencedRelations.length > 0 && (
|
||||
<div className="w-full flex flex-col justify-start items-start">
|
||||
{referencedRelations.map((relation) => (
|
||||
<RelationCard key={relation.memo!.name} memo={relation.memo!} mode="view" parentPage={parentPage} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetadataCard>
|
||||
);
|
||||
});
|
||||
|
||||
export default RelationList;
|
||||
|
|
@ -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";
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Common types for memo metadata components
|
||||
*/
|
||||
|
||||
export type DisplayMode = "edit" | "view";
|
||||
|
||||
export interface BaseMetadataProps {
|
||||
mode: DisplayMode;
|
||||
className?: string;
|
||||
}
|
||||
Loading…
Reference in New Issue