refactor: simplify memo metadata components

This commit is contained in:
memoclaw 2026-04-02 22:30:21 +08:00
parent 0e4d2d25ca
commit f403f8c03c
15 changed files with 264 additions and 178 deletions

View File

@ -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";

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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 => ({

View File

@ -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 && (

View File

@ -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">

View File

@ -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)}°`;
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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 ?? "";
};

View File

@ -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;
};

View File

@ -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",

View File

@ -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";