From f403f8c03c1186d7d4b7a6f2e03d7bca6ffcec21 Mon Sep 17 00:00:00 2001 From: memoclaw <265580040+memoclaw@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:30:21 +0800 Subject: [PATCH] refactor: simplify memo metadata components --- .../components/VoiceRecorderPanel.tsx | 2 +- .../Attachment/AttachmentListEditor.tsx | 40 ++++---- .../Attachment/AttachmentListView.tsx | 34 +++---- .../Attachment/AudioAttachmentItem.tsx | 2 +- ...entViewHelpers.ts => attachmentHelpers.ts} | 37 ++++---- .../Location/LocationDisplayEditor.tsx | 7 +- .../Location/LocationDisplayView.tsx | 13 ++- .../MemoMetadata/Location/locationHelpers.ts | 9 ++ .../MemoMetadata/MetadataSection.tsx | 24 +++++ .../Relation/RelationListEditor.tsx | 59 ++++-------- .../Relation/RelationListView.tsx | 92 ++++++++----------- .../MemoMetadata/Relation/relationHelpers.ts | 42 +++++++++ .../Relation/useResolvedRelationMemos.ts | 61 ++++++++++++ .../components/MemoMetadata/SectionHeader.tsx | 19 ++-- web/src/components/MemoMetadata/index.ts | 1 + 15 files changed, 264 insertions(+), 178 deletions(-) rename web/src/components/MemoMetadata/Attachment/{attachmentViewHelpers.ts => attachmentHelpers.ts} (77%) create mode 100644 web/src/components/MemoMetadata/Location/locationHelpers.ts create mode 100644 web/src/components/MemoMetadata/MetadataSection.tsx create mode 100644 web/src/components/MemoMetadata/Relation/relationHelpers.ts create mode 100644 web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts diff --git a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx index 78854655d..121ddbe65 100644 --- a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx +++ b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx @@ -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"; diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx index e9d03c2ce..bdbb8a20e 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx @@ -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 = ({ attachments, loca }; return ( -
- + + {items.map((item) => { + const isLocalFile = item.isLocal; + const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); -
- {items.map((item) => { - const isLocalFile = item.isLocal; - const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); - - return ( - handleRemoveItem(item)} - onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} - onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} - canMoveUp={!isLocalFile && attachmentIndex > 0} - canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} - /> - ); - })} -
-
+ return ( + handleRemoveItem(item)} + onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} + onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} + canMoveUp={!isLocalFile && attachmentIndex > 0} + canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} + /> + ); + })} + ); }; diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx index 0e9e911bd..5a6dec8a4 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListView.tsx @@ -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 = () =>
; 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 ( -
- - -
- {visual.length > 0 && } - - {visual.length > 0 && sectionCount > 1 && } - - {audio.length > 0 && } - - {audio.length > 0 && docs.length > 0 && } - - {docs.length > 0 && } -
-
+ + {hasVisual && } + {hasVisual && sectionCount > 1 && } + {hasAudio && } + {hasAudio && hasDocs && } + {hasDocs && } + ); }; diff --git a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx index 07694775f..c17df520a 100644 --- a/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx +++ b/web/src/components/MemoMetadata/Attachment/AudioAttachmentItem.tsx @@ -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; diff --git a/web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts b/web/src/components/MemoMetadata/Attachment/attachmentHelpers.ts similarity index 77% rename from web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts rename to web/src/components/MemoMetadata/Attachment/attachmentHelpers.ts index 40c04d8ab..f717a8cc3 100644 --- a/web/src/components/MemoMetadata/Attachment/attachmentViewHelpers.ts +++ b/web/src/components/MemoMetadata/Attachment/attachmentHelpers.ts @@ -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( + (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 => ({ diff --git a/web/src/components/MemoMetadata/Location/LocationDisplayEditor.tsx b/web/src/components/MemoMetadata/Location/LocationDisplayEditor.tsx index bb8c956df..23b2a4aea 100644 --- a/web/src/components/MemoMetadata/Location/LocationDisplayEditor.tsx +++ b/web/src/components/MemoMetadata/Location/LocationDisplayEditor.tsx @@ -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 = ({ location, onRemove, className }) => { - const displayText = location.placeholder || `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`; + const displayText = getLocationDisplayText(location); return (
= ({ location, onRem {displayText} - - {location.latitude.toFixed(4)}°, {location.longitude.toFixed(4)}° - + {getLocationCoordinatesText(location)}
{onRemove && ( diff --git a/web/src/components/MemoMetadata/Location/LocationDisplayView.tsx b/web/src/components/MemoMetadata/Location/LocationDisplayView.tsx index 378bee9d2..c1eb7b32c 100644 --- a/web/src/components/MemoMetadata/Location/LocationDisplayView.tsx +++ b/web/src/components/MemoMetadata/Location/LocationDisplayView.tsx @@ -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 ( -
setPopoverOpen(true)} > - - [{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°] - + [{getLocationCoordinatesText(location, 2)}] {displayText} -
+
diff --git a/web/src/components/MemoMetadata/Location/locationHelpers.ts b/web/src/components/MemoMetadata/Location/locationHelpers.ts new file mode 100644 index 000000000..6dae17f0b --- /dev/null +++ b/web/src/components/MemoMetadata/Location/locationHelpers.ts @@ -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)}°`; +}; diff --git a/web/src/components/MemoMetadata/MetadataSection.tsx b/web/src/components/MemoMetadata/MetadataSection.tsx new file mode 100644 index 000000000..cf7b94cb8 --- /dev/null +++ b/web/src/components/MemoMetadata/MetadataSection.tsx @@ -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 ( +
+ +
{children}
+
+ ); +}; + +export default MetadataSection; diff --git a/web/src/components/MemoMetadata/Relation/RelationListEditor.tsx b/web/src/components/MemoMetadata/Relation/RelationListEditor.tsx index aca304c3b..1ded6b475 100644 --- a/web/src/components/MemoMetadata/Relation/RelationListEditor.tsx +++ b/web/src/components/MemoMetadata/Relation/RelationListEditor.tsx @@ -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 = ({ 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>({}); - - 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 = ({ relations, onRelation } return ( -
- - -
- {referenceRelations.map((relation) => { - const relatedMemo = relation.relatedMemo!; - const memo = relatedMemo.snippet ? relatedMemo : fetchedMemos[relatedMemo.name] || relatedMemo; - return handleDeleteRelation(memo.name)} parentPage={parentPage} />; - })} -
-
+ + {referenceRelations.map((relation) => { + const relatedMemo = relation.relatedMemo!; + const memo = relatedMemo.snippet ? relatedMemo : resolvedMemos[relatedMemo.name] || relatedMemo; + return handleDeleteRelation(memo.name)} parentPage={parentPage} />; + })} + ); }; diff --git a/web/src/components/MemoMetadata/Relation/RelationListView.tsx b/web/src/components/MemoMetadata/Relation/RelationListView.tsx index 8ac60ed6a..aa2f6e171 100644 --- a/web/src/components/MemoMetadata/Relation/RelationListView.tsx +++ b/web/src/components/MemoMetadata/Relation/RelationListView.tsx @@ -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 ( -
- setActiveTab("referencing"), - }, - { - id: "referenced", - label: t("common.referenced-by"), - count: referencedRelations.length, - active: !isReferencing, - onClick: () => setActiveTab("referenced"), - }, - ] - : undefined - } - /> - -
- {activeRelations.map((relation) => ( - - ))} -
-
+ 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) => ( + + ))} + ); } diff --git a/web/src/components/MemoMetadata/Relation/relationHelpers.ts b/web/src/components/MemoMetadata/Relation/relationHelpers.ts new file mode 100644 index 000000000..5d0744dc5 --- /dev/null +++ b/web/src/components/MemoMetadata/Relation/relationHelpers.ts @@ -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 ?? ""; +}; diff --git a/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts b/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts new file mode 100644 index 000000000..4a505bf7a --- /dev/null +++ b/web/src/components/MemoMetadata/Relation/useResolvedRelationMemos.ts @@ -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>({}); + + const missingMemoNames = useMemo(() => { + const names = new Set(); + + 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; +}; diff --git a/web/src/components/MemoMetadata/SectionHeader.tsx b/web/src/components/MemoMetadata/SectionHeader.tsx index 7ae1de43b..e753e8a03 100644 --- a/web/src/components/MemoMetadata/SectionHeader.tsx +++ b/web/src/components/MemoMetadata/SectionHeader.tsx @@ -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) => (