mirror of https://github.com/usememos/memos.git
chore(web): unify Location/Attachments/Relations components (#5241)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a2ccf6b201
commit
fc43f86571
|
|
@ -1,113 +0,0 @@
|
||||||
import { memo, useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
|
||||||
import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
|
|
||||||
import MemoAttachment from "./MemoAttachment";
|
|
||||||
import PreviewImageDialog from "./PreviewImageDialog";
|
|
||||||
|
|
||||||
const MemoAttachmentListView = ({ attachments = [] }: { attachments: Attachment[] }) => {
|
|
||||||
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number }>({
|
|
||||||
open: false,
|
|
||||||
urls: [],
|
|
||||||
index: 0,
|
|
||||||
});
|
|
||||||
const mediaAttachments: Attachment[] = [];
|
|
||||||
const otherAttachments: Attachment[] = [];
|
|
||||||
|
|
||||||
attachments.forEach((attachment) => {
|
|
||||||
const type = getAttachmentType(attachment);
|
|
||||||
if (type === "image/*" || type === "video/*") {
|
|
||||||
mediaAttachments.push(attachment);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
otherAttachments.push(attachment);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleImageClick = (imgUrl: string) => {
|
|
||||||
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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const MediaCard = ({ attachment, className }: { attachment: Attachment; className?: string }) => {
|
|
||||||
const type = getAttachmentType(attachment);
|
|
||||||
const attachmentUrl = getAttachmentUrl(attachment);
|
|
||||||
const attachmentThumbnailUrl = getAttachmentThumbnailUrl(attachment);
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
// Fallback to original image if thumbnail fails
|
|
||||||
const target = e.target as HTMLImageElement;
|
|
||||||
if (target.src.includes("?thumbnail=true")) {
|
|
||||||
console.warn("Thumbnail failed, falling back to original image:", attachmentUrl);
|
|
||||||
target.src = attachmentUrl;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => handleImageClick(attachmentUrl)}
|
|
||||||
decoding="async"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} 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
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const MediaList = ({ attachments = [] }: { attachments: Attachment[] }) => {
|
|
||||||
const cards = attachments.map((attachment) => (
|
|
||||||
<div key={attachment.name} className="max-w-[60%] w-fit flex flex-col justify-start items-start shrink-0">
|
|
||||||
<MediaCard className="max-h-64 grow" attachment={attachment} />
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
return <div className="w-full flex flex-row justify-start overflow-auto gap-2">{cards}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const OtherList = ({ attachments = [] }: { attachments: Attachment[] }) => {
|
|
||||||
if (attachments.length === 0) return <></>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
|
|
||||||
{otherAttachments.map((attachment) => (
|
|
||||||
<MemoAttachment key={attachment.name} attachment={attachment} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{mediaAttachments.length > 0 && <MediaList attachments={mediaAttachments} />}
|
|
||||||
<OtherList attachments={otherAttachments} />
|
|
||||||
|
|
||||||
<PreviewImageDialog
|
|
||||||
open={previewImage.open}
|
|
||||||
onOpenChange={(open) => setPreviewImage((prev) => ({ ...prev, open }))}
|
|
||||||
imgUrls={previewImage.urls}
|
|
||||||
initialIndex={previewImage.index}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(MemoAttachmentListView);
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { DndContext, closestCenter, MouseSensor, TouchSensor, useSensor, useSensors, DragEndEvent } from "@dnd-kit/core";
|
|
||||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
||||||
import { FileIcon, XIcon } from "lucide-react";
|
|
||||||
import { Attachment } from "@/types/proto/api/v1/attachment_service";
|
|
||||||
import { getAttachmentThumbnailUrl, getAttachmentType } from "@/utils/attachment";
|
|
||||||
import SortableItem from "./SortableItem";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
attachmentList: Attachment[];
|
|
||||||
setAttachmentList: (attachmentList: Attachment[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AttachmentListView = (props: Props) => {
|
|
||||||
const { attachmentList, setAttachmentList } = props;
|
|
||||||
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
|
|
||||||
|
|
||||||
const handleDeleteAttachment = async (name: string) => {
|
|
||||||
setAttachmentList(attachmentList.filter((attachment) => attachment.name !== name));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
const oldIndex = attachmentList.findIndex((attachment) => attachment.name === active.id);
|
|
||||||
const newIndex = attachmentList.findIndex((attachment) => attachment.name === over.id);
|
|
||||||
|
|
||||||
setAttachmentList(arrayMove(attachmentList, oldIndex, newIndex));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
||||||
<SortableContext items={attachmentList.map((attachment) => attachment.name)} strategy={verticalListSortingStrategy}>
|
|
||||||
{attachmentList.length > 0 && (
|
|
||||||
<div className="w-full flex flex-row justify-start flex-wrap gap-2 mt-2 max-h-[50vh] overflow-y-auto">
|
|
||||||
{attachmentList.map((attachment) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={attachment.name}
|
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
<SortableItem id={attachment.name} className="flex items-center gap-1.5 min-w-0">
|
|
||||||
{getAttachmentType(attachment) === "image/*" ? (
|
|
||||||
<img
|
|
||||||
src={getAttachmentThumbnailUrl(attachment)}
|
|
||||||
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>
|
|
||||||
</SortableItem>
|
|
||||||
<button
|
|
||||||
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
|
||||||
onClick={() => handleDeleteAttachment(attachment.name)}
|
|
||||||
>
|
|
||||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AttachmentListView;
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import { MapPinIcon, XIcon } from "lucide-react";
|
|
||||||
import { Location } from "@/types/proto/api/v1/memo_service";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
location?: Location;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationView = (props: Props) => {
|
|
||||||
if (!props.location) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full flex flex-row flex-wrap gap-2 mt-2">
|
|
||||||
<div className="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">
|
|
||||||
<MapPinIcon className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
|
||||||
<span className="truncate max-w-[160px]">{props.location.placeholder}</span>
|
|
||||||
<button
|
|
||||||
className="shrink-0 rounded hover:bg-accent transition-colors p-0.5"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
props.onRemove();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XIcon className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocationView;
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { LinkIcon, XIcon } from "lucide-react";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { memoStore } from "@/store";
|
|
||||||
import { Memo, MemoRelation, MemoRelation_Type } from "@/types/proto/api/v1/memo_service";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
relationList: MemoRelation[];
|
|
||||||
setRelationList: (relationList: MemoRelation[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RelationListView = observer((props: Props) => {
|
|
||||||
const { relationList, setRelationList } = props;
|
|
||||||
const [referencingMemoList, setReferencingMemoList] = useState<Memo[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
const requests = relationList
|
|
||||||
.filter((relation) => relation.type === MemoRelation_Type.REFERENCE)
|
|
||||||
.map(async (relation) => {
|
|
||||||
return await memoStore.getOrFetchMemoByName(relation.relatedMemo!.name, { skipStore: true });
|
|
||||||
});
|
|
||||||
const list = await Promise.all(requests);
|
|
||||||
setReferencingMemoList(list);
|
|
||||||
})();
|
|
||||||
}, [relationList]);
|
|
||||||
|
|
||||||
const handleDeleteRelation = async (memo: Memo) => {
|
|
||||||
setRelationList(relationList.filter((relation) => relation.relatedMemo?.name !== memo.name));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{referencingMemoList.length > 0 && (
|
|
||||||
<div className="w-full flex flex-row gap-2 mt-2 flex-wrap">
|
|
||||||
{referencingMemoList.map((memo) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={memo.name}
|
|
||||||
className="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"
|
|
||||||
onClick={() => handleDeleteRelation(memo)}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default RelationListView;
|
|
||||||
|
|
@ -20,12 +20,10 @@ import { Location, Memo, MemoRelation, MemoRelation_Type, Visibility } from "@/t
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityFromString } from "@/utils/memo";
|
import { convertVisibilityFromString } from "@/utils/memo";
|
||||||
import DateTimeInput from "../DateTimeInput";
|
import DateTimeInput from "../DateTimeInput";
|
||||||
|
import { LocationDisplay, AttachmentList, RelationList } from "../memo-metadata";
|
||||||
import InsertMenu from "./ActionButton/InsertMenu";
|
import InsertMenu from "./ActionButton/InsertMenu";
|
||||||
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
import VisibilitySelector from "./ActionButton/VisibilitySelector";
|
||||||
import AttachmentListView from "./AttachmentListView";
|
|
||||||
import Editor, { EditorRefActions } from "./Editor";
|
import Editor, { EditorRefActions } from "./Editor";
|
||||||
import LocationView from "./LocationView";
|
|
||||||
import RelationListView from "./RelationListView";
|
|
||||||
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers";
|
||||||
import { MemoEditorContext } from "./types";
|
import { MemoEditorContext } from "./types";
|
||||||
|
|
||||||
|
|
@ -490,7 +488,8 @@ const MemoEditor = observer((props: Props) => {
|
||||||
onCompositionEnd={handleCompositionEnd}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
>
|
>
|
||||||
<Editor ref={editorRef} {...editorConfig} />
|
<Editor ref={editorRef} {...editorConfig} />
|
||||||
<LocationView
|
<LocationDisplay
|
||||||
|
mode="edit"
|
||||||
location={state.location}
|
location={state.location}
|
||||||
onRemove={() =>
|
onRemove={() =>
|
||||||
setState((prevState) => ({
|
setState((prevState) => ({
|
||||||
|
|
@ -499,8 +498,8 @@ const MemoEditor = observer((props: Props) => {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
<AttachmentList mode="edit" attachments={state.attachmentList} onAttachmentsChange={handleSetAttachmentList} />
|
||||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
<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="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">
|
<div className="flex flex-row justify-start items-center gap-1">
|
||||||
<InsertMenu
|
<InsertMenu
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { LatLng } from "leaflet";
|
|
||||||
import { 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";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
location: Location;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MemoLocationView: React.FC<Props> = (props: Props) => {
|
|
||||||
const { location } = props;
|
|
||||||
const [popoverOpen, setPopoverOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<p className="w-full flex flex-row gap-0.5 items-center text-muted-foreground hover:text-foreground cursor-pointer transition-colors">
|
|
||||||
<MapPinIcon className="w-4 h-auto shrink-0" />
|
|
||||||
<span className="text-sm font-normal text-ellipsis whitespace-nowrap overflow-hidden">
|
|
||||||
{location.placeholder ? location.placeholder : `[${location.latitude}, ${location.longitude}]`}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</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 MemoLocationView;
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div className="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">
|
|
||||||
<div className="w-full flex flex-row justify-start items-center mb-1 gap-3 opacity-60">
|
|
||||||
{referencingMemoList.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",
|
|
||||||
selectedTab === "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">({referencingMemoList.length})</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{referencedMemoList.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",
|
|
||||||
selectedTab === "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">({referencedMemoList.length})</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{selectedTab === "referencing" && referencingMemoList.length > 0 && (
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
{referencingMemoList.map((memo) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={memo.name}
|
|
||||||
className="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"
|
|
||||||
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">
|
|
||||||
{extractMemoIdFromName(memo.name).slice(0, 6)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{memo.snippet}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedTab === "referenced" && referencedMemoList.length > 0 && (
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
|
||||||
{referencedMemoList.map((memo) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={memo.name}
|
|
||||||
className="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"
|
|
||||||
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">
|
|
||||||
{extractMemoIdFromName(memo.name).slice(0, 6)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{memo.snippet}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(MemoRelationListView);
|
|
||||||
|
|
@ -14,16 +14,14 @@ import { useTranslate } from "@/utils/i18n";
|
||||||
import { convertVisibilityToString } from "@/utils/memo";
|
import { convertVisibilityToString } from "@/utils/memo";
|
||||||
import { isSuperUser } from "@/utils/user";
|
import { isSuperUser } from "@/utils/user";
|
||||||
import MemoActionMenu from "./MemoActionMenu";
|
import MemoActionMenu from "./MemoActionMenu";
|
||||||
import MemoAttachmentListView from "./MemoAttachmentListView";
|
|
||||||
import MemoContent from "./MemoContent";
|
import MemoContent from "./MemoContent";
|
||||||
import MemoEditor from "./MemoEditor";
|
import MemoEditor from "./MemoEditor";
|
||||||
import MemoLocationView from "./MemoLocationView";
|
|
||||||
import MemoReactionistView from "./MemoReactionListView";
|
import MemoReactionistView from "./MemoReactionListView";
|
||||||
import MemoRelationListView from "./MemoRelationListView";
|
|
||||||
import PreviewImageDialog from "./PreviewImageDialog";
|
import PreviewImageDialog from "./PreviewImageDialog";
|
||||||
import ReactionSelector from "./ReactionSelector";
|
import ReactionSelector from "./ReactionSelector";
|
||||||
import UserAvatar from "./UserAvatar";
|
import UserAvatar from "./UserAvatar";
|
||||||
import VisibilityIcon from "./VisibilityIcon";
|
import VisibilityIcon from "./VisibilityIcon";
|
||||||
|
import { LocationDisplay, AttachmentList, RelationList } from "./memo-metadata";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
memo: Memo;
|
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.
|
compact={memo.pinned ? false : props.compact} // Always show full content when pinned.
|
||||||
parentPage={parentPage}
|
parentPage={parentPage}
|
||||||
/>
|
/>
|
||||||
{memo.location && <MemoLocationView location={memo.location} />}
|
{memo.location && <LocationDisplay mode="view" location={memo.location} />}
|
||||||
<MemoAttachmentListView attachments={memo.attachments} />
|
<AttachmentList mode="view" attachments={memo.attachments} />
|
||||||
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
|
<RelationList mode="view" relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
||||||
<MemoReactionistView memo={memo} reactions={memo.reactions} />
|
<MemoReactionistView memo={memo} reactions={memo.reactions} />
|
||||||
</div>
|
</div>
|
||||||
{nsfw && !showNSFWContent && (
|
{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