mirror of https://github.com/usememos/memos.git
refactor: simplify memo metadata components
This commit is contained in:
parent
0e4d2d25ca
commit
f403f8c03c
|
|
@ -1,7 +1,7 @@
|
|||
import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment";
|
||||
import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentViewHelpers";
|
||||
import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "
|
|||
import type { FC } from "react";
|
||||
import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment";
|
||||
import { toAttachmentItems } from "@/components/MemoEditor/types/attachment";
|
||||
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
|
||||
interface AttachmentListEditorProps {
|
||||
attachments: Attachment[];
|
||||
|
|
@ -142,28 +142,24 @@ const AttachmentListEditor: FC<AttachmentListEditorProps> = ({ attachments, loca
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<SectionHeader icon={PaperclipIcon} title="Attachments" count={items.length} />
|
||||
<MetadataSection icon={PaperclipIcon} title="Attachments" count={items.length} contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5">
|
||||
{items.map((item) => {
|
||||
const isLocalFile = item.isLocal;
|
||||
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
|
||||
|
||||
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
|
||||
{items.map((item) => {
|
||||
const isLocalFile = item.isLocal;
|
||||
const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id);
|
||||
|
||||
return (
|
||||
<AttachmentItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={() => handleRemoveItem(item)}
|
||||
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
|
||||
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
|
||||
canMoveUp={!isLocalFile && attachmentIndex > 0}
|
||||
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<AttachmentItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onRemove={() => handleRemoveItem(item)}
|
||||
onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined}
|
||||
onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined}
|
||||
canMoveUp={!isLocalFile && attachmentIndex > 0}
|
||||
canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetadataSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { DownloadIcon, FileIcon, Maximize2Icon, PaperclipIcon, PlayIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import { getAttachmentUrl } from "@/utils/attachment";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import AttachmentCard from "./AttachmentCard";
|
||||
import AudioAttachmentItem from "./AudioAttachmentItem";
|
||||
import { getAttachmentMetadata, isImageAttachment, isVideoAttachment, separateAttachments } from "./attachmentViewHelpers";
|
||||
import { getAttachmentMetadata, isImageAttachment, isVideoAttachment, separateAttachments } from "./attachmentHelpers";
|
||||
|
||||
interface AttachmentListViewProps {
|
||||
attachments: Attachment[];
|
||||
|
|
@ -172,9 +172,12 @@ const Divider = () => <div className="border-t border-border/70 opacity-80" />;
|
|||
|
||||
const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewProps) => {
|
||||
const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
|
||||
|
||||
const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]);
|
||||
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]);
|
||||
const hasVisual = visual.length > 0;
|
||||
const hasAudio = audio.length > 0;
|
||||
const hasDocs = docs.length > 0;
|
||||
const sectionCount = [hasVisual, hasAudio, hasDocs].filter(Boolean).length;
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return null;
|
||||
|
|
@ -185,25 +188,14 @@ const AttachmentListView = ({ attachments, onImagePreview }: AttachmentListViewP
|
|||
onImagePreview?.(imageUrls, index >= 0 ? index : 0);
|
||||
};
|
||||
|
||||
const sections = [visual.length > 0, audio.length > 0, docs.length > 0];
|
||||
const sectionCount = sections.filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
|
||||
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{visual.length > 0 && <VisualSection attachments={visual} onImageClick={handleImageClick} />}
|
||||
|
||||
{visual.length > 0 && sectionCount > 1 && <Divider />}
|
||||
|
||||
{audio.length > 0 && <AudioList attachments={audio} />}
|
||||
|
||||
{audio.length > 0 && docs.length > 0 && <Divider />}
|
||||
|
||||
{docs.length > 0 && <DocsList attachments={docs} />}
|
||||
</div>
|
||||
</div>
|
||||
<MetadataSection icon={PaperclipIcon} title="Attachments" count={attachments.length} contentClassName="flex flex-col gap-2 p-2">
|
||||
{hasVisual && <VisualSection attachments={visual} onImageClick={handleImageClick} />}
|
||||
{hasVisual && sectionCount > 1 && <Divider />}
|
||||
{hasAudio && <AudioList attachments={audio} />}
|
||||
{hasAudio && hasDocs && <Divider />}
|
||||
{hasDocs && <DocsList attachments={docs} />}
|
||||
</MetadataSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { FileAudioIcon, PauseIcon, PlayIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { formatFileSize, getFileTypeLabel } from "@/utils/format";
|
||||
import { formatAudioTime } from "./attachmentViewHelpers";
|
||||
import { formatAudioTime } from "./attachmentHelpers";
|
||||
|
||||
const AUDIO_PLAYBACK_RATES = [1, 1.5, 2] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,27 +18,24 @@ export const isVideoAttachment = (attachment: Attachment): boolean => getAttachm
|
|||
export const isAudioAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "audio/*";
|
||||
|
||||
export const separateAttachments = (attachments: Attachment[]): AttachmentGroups => {
|
||||
const groups: AttachmentGroups = {
|
||||
visual: [],
|
||||
audio: [],
|
||||
docs: [],
|
||||
};
|
||||
return attachments.reduce<AttachmentGroups>(
|
||||
(groups, attachment) => {
|
||||
if (isImageAttachment(attachment) || isVideoAttachment(attachment)) {
|
||||
groups.visual.push(attachment);
|
||||
} else if (isAudioAttachment(attachment)) {
|
||||
groups.audio.push(attachment);
|
||||
} else {
|
||||
groups.docs.push(attachment);
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (isImageAttachment(attachment) || isVideoAttachment(attachment)) {
|
||||
groups.visual.push(attachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAudioAttachment(attachment)) {
|
||||
groups.audio.push(attachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.docs.push(attachment);
|
||||
}
|
||||
|
||||
return groups;
|
||||
return groups;
|
||||
},
|
||||
{
|
||||
visual: [],
|
||||
audio: [],
|
||||
docs: [],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getAttachmentMetadata = (attachment: Attachment): AttachmentMetadata => ({
|
||||
|
|
@ -2,6 +2,7 @@ import { MapPinIcon, XIcon } from "lucide-react";
|
|||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { getLocationCoordinatesText, getLocationDisplayText } from "./locationHelpers";
|
||||
|
||||
interface LocationDisplayEditorProps {
|
||||
location: Location;
|
||||
|
|
@ -10,7 +11,7 @@ interface LocationDisplayEditorProps {
|
|||
}
|
||||
|
||||
const LocationDisplayEditor: FC<LocationDisplayEditorProps> = ({ location, onRemove, className }) => {
|
||||
const displayText = location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
|
||||
const displayText = getLocationDisplayText(location);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -25,9 +26,7 @@ const LocationDisplayEditor: FC<LocationDisplayEditorProps> = ({ location, onRem
|
|||
<span className="text-xs truncate" title={displayText}>
|
||||
{displayText}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 hidden sm:inline">
|
||||
{location.latitude.toFixed(4)}°, {location.longitude.toFixed(4)}°
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 hidden sm:inline">{getLocationCoordinatesText(location)}</span>
|
||||
</div>
|
||||
|
||||
{onRemove && (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { LocationPicker } from "@/components/map";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { getLocationCoordinatesText, getLocationDisplayText } from "./locationHelpers";
|
||||
|
||||
interface LocationDisplayViewProps {
|
||||
location?: Location;
|
||||
|
|
@ -18,27 +19,25 @@ const LocationDisplayView = ({ location, className }: LocationDisplayViewProps)
|
|||
return null;
|
||||
}
|
||||
|
||||
const displayText = location.placeholder || `Position: [${location.latitude}, ${location.longitude}]`;
|
||||
const displayText = getLocationDisplayText(location);
|
||||
|
||||
return (
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex flex-row gap-2 cursor-pointer",
|
||||
"relative inline-flex items-center gap-1.5 px-2 h-7 rounded-md border border-border bg-muted/20 hover:bg-accent/20 text-muted-foreground hover:text-foreground text-xs transition-colors",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setPopoverOpen(true)}
|
||||
>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
<MapPinIcon className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
<span className="text-nowrap opacity-80">
|
||||
[{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°]
|
||||
</span>
|
||||
<span className="text-nowrap opacity-80">[{getLocationCoordinatesText(location, 2)}]</span>
|
||||
<span className="text-nowrap truncate">{displayText}</span>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start">
|
||||
<div className="min-w-80 sm:w-lg flex flex-col justify-start items-start">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export const getLocationDisplayText = (location: Location): string => {
|
||||
return location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
|
||||
};
|
||||
|
||||
export const getLocationCoordinatesText = (location: Location, digits = 4): string => {
|
||||
return `${location.latitude.toFixed(digits)}°, ${location.longitude.toFixed(digits)}°`;
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SectionHeader, { type SectionHeaderTab } from "./SectionHeader";
|
||||
|
||||
interface MetadataSectionProps extends PropsWithChildren {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
count: number;
|
||||
tabs?: SectionHeaderTab[];
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const MetadataSection = ({ icon, title, count, tabs, className, contentClassName, children }: MetadataSectionProps) => {
|
||||
return (
|
||||
<div className={cn("w-full overflow-hidden rounded-lg border border-border bg-muted/20", className)}>
|
||||
<SectionHeader icon={icon} title={title} count={count} tabs={tabs} />
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetadataSection;
|
||||
|
|
@ -1,12 +1,11 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { LinkIcon, XIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import { useMemo } from "react";
|
||||
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoRelation_Memo, MemoRelation_MemoSchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import RelationCard from "./RelationCard";
|
||||
import { getEditorReferenceRelations } from "./relationHelpers";
|
||||
import { useResolvedRelationMemos } from "./useResolvedRelationMemos";
|
||||
|
||||
interface RelationListEditorProps {
|
||||
relations: MemoRelation[];
|
||||
|
|
@ -40,31 +39,8 @@ const RelationItemCard: FC<{
|
|||
};
|
||||
|
||||
const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelationsChange, parentPage, memoName }) => {
|
||||
const referenceRelations = useMemo(
|
||||
() => relations.filter((r) => r.type === MemoRelation_Type.REFERENCE && (!memoName || !r.memo?.name || r.memo.name === memoName)),
|
||||
[relations, memoName],
|
||||
);
|
||||
const [fetchedMemos, setFetchedMemos] = useState<Record<string, MemoRelation_Memo>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const missingSnippetRelations = referenceRelations.filter((relation) => !relation.relatedMemo?.snippet && relation.relatedMemo?.name);
|
||||
if (missingSnippetRelations.length > 0) {
|
||||
const requests = missingSnippetRelations.map(async (relation) => {
|
||||
const memo = await memoServiceClient.getMemo({ name: relation.relatedMemo!.name });
|
||||
return create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet });
|
||||
});
|
||||
const list = await Promise.all(requests);
|
||||
setFetchedMemos((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const memo of list) {
|
||||
next[memo.name] = memo;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [referenceRelations]);
|
||||
const referenceRelations = useMemo(() => getEditorReferenceRelations(relations, memoName), [relations, memoName]);
|
||||
const resolvedMemos = useResolvedRelationMemos(referenceRelations);
|
||||
|
||||
const handleDeleteRelation = (memoName: string) => {
|
||||
if (onRelationsChange) {
|
||||
|
|
@ -77,17 +53,18 @@ const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelation
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
|
||||
<SectionHeader icon={LinkIcon} title="Relations" count={referenceRelations.length} />
|
||||
|
||||
<div className="p-1 sm:p-1.5 flex flex-col gap-0.5">
|
||||
{referenceRelations.map((relation) => {
|
||||
const relatedMemo = relation.relatedMemo!;
|
||||
const memo = relatedMemo.snippet ? relatedMemo : fetchedMemos[relatedMemo.name] || relatedMemo;
|
||||
return <RelationItemCard key={memo.name} memo={memo} onRemove={() => handleDeleteRelation(memo.name)} parentPage={parentPage} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<MetadataSection
|
||||
icon={LinkIcon}
|
||||
title="Relations"
|
||||
count={referenceRelations.length}
|
||||
contentClassName="flex flex-col gap-0.5 p-1 sm:p-1.5"
|
||||
>
|
||||
{referenceRelations.map((relation) => {
|
||||
const relatedMemo = relation.relatedMemo!;
|
||||
const memo = relatedMemo.snippet ? relatedMemo : resolvedMemos[relatedMemo.name] || relatedMemo;
|
||||
return <RelationItemCard key={memo.name} memo={memo} onRemove={() => handleDeleteRelation(memo.name)} parentPage={parentPage} />;
|
||||
})}
|
||||
</MetadataSection>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { LinkIcon, MilestoneIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import MetadataSection from "@/components/MemoMetadata/MetadataSection";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import SectionHeader from "../SectionHeader";
|
||||
import RelationCard from "./RelationCard";
|
||||
import { getRelationBuckets, getRelationMemo, getRelationMemoName, type RelationDirection } from "./relationHelpers";
|
||||
|
||||
interface RelationListViewProps {
|
||||
relations: MemoRelation[];
|
||||
|
|
@ -18,66 +17,53 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
|
|||
const t = useTranslate();
|
||||
const [activeTab, setActiveTab] = useState<"referencing" | "referenced">("referencing");
|
||||
|
||||
const { referencingRelations, referencedRelations } = useMemo(() => {
|
||||
return {
|
||||
referencingRelations: relations.filter(
|
||||
(r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name === currentMemoName && r.relatedMemo?.name !== currentMemoName,
|
||||
),
|
||||
referencedRelations: relations.filter(
|
||||
(r) => r.type === MemoRelation_Type.REFERENCE && r.memo?.name !== currentMemoName && r.relatedMemo?.name === currentMemoName,
|
||||
),
|
||||
};
|
||||
}, [relations, currentMemoName]);
|
||||
const { referencing: referencingRelations, referenced: referencedRelations } = useMemo(
|
||||
() => getRelationBuckets(relations, currentMemoName),
|
||||
[relations, currentMemoName],
|
||||
);
|
||||
|
||||
if (referencingRelations.length === 0 && referencedRelations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasBothTabs = referencingRelations.length > 0 && referencedRelations.length > 0;
|
||||
const defaultTab = referencingRelations.length > 0 ? "referencing" : "referenced";
|
||||
const tab = hasBothTabs ? activeTab : defaultTab;
|
||||
const isReferencing = tab === "referencing";
|
||||
const direction: RelationDirection = hasBothTabs ? activeTab : referencingRelations.length > 0 ? "referencing" : "referenced";
|
||||
const isReferencing = direction === "referencing";
|
||||
const icon = isReferencing ? LinkIcon : MilestoneIcon;
|
||||
const activeRelations = isReferencing ? referencingRelations : referencedRelations;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full rounded-lg border border-border bg-muted/20 overflow-hidden", className)}>
|
||||
<SectionHeader
|
||||
icon={icon}
|
||||
title={isReferencing ? t("common.referencing") : t("common.referenced-by")}
|
||||
count={activeRelations.length}
|
||||
tabs={
|
||||
hasBothTabs
|
||||
? [
|
||||
{
|
||||
id: "referencing",
|
||||
label: t("common.referencing"),
|
||||
count: referencingRelations.length,
|
||||
active: isReferencing,
|
||||
onClick: () => setActiveTab("referencing"),
|
||||
},
|
||||
{
|
||||
id: "referenced",
|
||||
label: t("common.referenced-by"),
|
||||
count: referencedRelations.length,
|
||||
active: !isReferencing,
|
||||
onClick: () => setActiveTab("referenced"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-1.5 flex flex-col gap-0">
|
||||
{activeRelations.map((relation) => (
|
||||
<RelationCard
|
||||
key={isReferencing ? relation.relatedMemo!.name : relation.memo!.name}
|
||||
memo={isReferencing ? relation.relatedMemo! : relation.memo!}
|
||||
parentPage={parentPage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<MetadataSection
|
||||
className={className}
|
||||
icon={icon}
|
||||
title={isReferencing ? t("common.referencing") : t("common.referenced-by")}
|
||||
count={activeRelations.length}
|
||||
tabs={
|
||||
hasBothTabs
|
||||
? [
|
||||
{
|
||||
id: "referencing",
|
||||
label: t("common.referencing"),
|
||||
count: referencingRelations.length,
|
||||
active: isReferencing,
|
||||
onClick: () => setActiveTab("referencing"),
|
||||
},
|
||||
{
|
||||
id: "referenced",
|
||||
label: t("common.referenced-by"),
|
||||
count: referencedRelations.length,
|
||||
active: !isReferencing,
|
||||
onClick: () => setActiveTab("referenced"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
contentClassName="flex flex-col gap-0 p-1.5"
|
||||
>
|
||||
{activeRelations.map((relation) => (
|
||||
<RelationCard key={getRelationMemoName(relation, direction)} memo={getRelationMemo(relation, direction)!} parentPage={parentPage} />
|
||||
))}
|
||||
</MetadataSection>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export type RelationDirection = "referencing" | "referenced";
|
||||
|
||||
export const isReferenceRelation = (relation: MemoRelation): boolean => relation.type === MemoRelation_Type.REFERENCE;
|
||||
|
||||
export const getEditorReferenceRelations = (relations: MemoRelation[], memoName?: string): MemoRelation[] => {
|
||||
return relations.filter(
|
||||
(relation) => isReferenceRelation(relation) && (!memoName || !relation.memo?.name || relation.memo.name === memoName),
|
||||
);
|
||||
};
|
||||
|
||||
export const getRelationBuckets = (relations: MemoRelation[], currentMemoName?: string) => {
|
||||
return relations.reduce(
|
||||
(groups, relation) => {
|
||||
if (!isReferenceRelation(relation)) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
if (relation.memo?.name === currentMemoName && relation.relatedMemo?.name !== currentMemoName) {
|
||||
groups.referencing.push(relation);
|
||||
} else if (relation.memo?.name !== currentMemoName && relation.relatedMemo?.name === currentMemoName) {
|
||||
groups.referenced.push(relation);
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
{
|
||||
referencing: [] as MemoRelation[],
|
||||
referenced: [] as MemoRelation[],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getRelationMemo = (relation: MemoRelation, direction: RelationDirection) => {
|
||||
return direction === "referencing" ? relation.relatedMemo : relation.memo;
|
||||
};
|
||||
|
||||
export const getRelationMemoName = (relation: MemoRelation, direction: RelationDirection): string => {
|
||||
return getRelationMemo(relation, direction)?.name ?? "";
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { create } from "@bufbuild/protobuf";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { memoServiceClient } from "@/connect";
|
||||
import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export const useResolvedRelationMemos = (relations: MemoRelation[]) => {
|
||||
const [resolvedMemos, setResolvedMemos] = useState<Record<string, MemoRelation_Memo>>({});
|
||||
|
||||
const missingMemoNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const relation of relations) {
|
||||
const relatedMemo = relation.relatedMemo;
|
||||
if (relatedMemo?.name && !relatedMemo.snippet && !resolvedMemos[relatedMemo.name]) {
|
||||
names.add(relatedMemo.name);
|
||||
}
|
||||
}
|
||||
|
||||
return [...names];
|
||||
}, [relations, resolvedMemos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (missingMemoNames.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const memos = await Promise.all(
|
||||
missingMemoNames.map(async (name) => {
|
||||
const memo = await memoServiceClient.getMemo({ name });
|
||||
return create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet });
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedMemos((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const memo of memos) {
|
||||
next[memo.name] = memo;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep existing relation data when snippet hydration fails.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [missingMemoNames]);
|
||||
|
||||
return resolvedMemos;
|
||||
};
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
import { LucideIcon } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SectionHeaderTab {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
count: number;
|
||||
tabs?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
tabs?: SectionHeaderTab[];
|
||||
}
|
||||
|
||||
const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) => {
|
||||
|
|
@ -24,6 +26,7 @@ const SectionHeader = ({ icon: Icon, title, count, tabs }: SectionHeaderProps) =
|
|||
{tabs.map((tab, idx) => (
|
||||
<div key={tab.id} className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={tab.onClick}
|
||||
className={cn(
|
||||
"text-xs px-0 py-0 transition-colors",
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@
|
|||
|
||||
export { AttachmentCard, AttachmentListEditor, AttachmentListView } from "./Attachment";
|
||||
export { LocationDialog, LocationDisplayEditor, LocationDisplayView } from "./Location";
|
||||
export { default as MetadataSection } from "./MetadataSection";
|
||||
export { LinkMemoDialog, RelationCard, RelationListEditor, RelationListView } from "./Relation";
|
||||
export { default as SectionHeader } from "./SectionHeader";
|
||||
|
|
|
|||
Loading…
Reference in New Issue