From d4b5a1695b9acf4da3ef72f600789a4a0f90e4d5 Mon Sep 17 00:00:00 2001 From: boojack Date: Thu, 16 Jul 2026 23:59:31 +0800 Subject: [PATCH] feat(web): redesign memo detail sidebar as property rail Rework MemoDetailSidebar into a Linear-style rail: a quiet icon action cluster (pin, copy link, share image, share links) followed by label/value property rows for visibility, created/edited times, author, and location, then hairline-separated sections for tags, attachments, relations, and the outline. Attachments and reference relations were previously not surfaced in the sidebar at all. - Visibility is editable in place, reusing VisibilitySelector with a new compact size variant that matches the 13px row grammar - Pin toggle and visibility changes go through useUpdateMemo - MemoOutline gains active-section tracking (scroll-spy), depth indentation, and tick-rail styling; drops the animated underline and native title tooltips - Widen the detail rail from w-56 to w-60 --- .../MemoDetailSidebar/MemoDetailSidebar.tsx | 357 +++++++++++++----- .../MemoDetailSidebar/MemoOutline.tsx | 90 +++-- .../MemoEditor/Toolbar/VisibilitySelector.tsx | 15 +- .../components/MemoEditor/types/components.ts | 2 + web/src/locales/en.json | 4 +- web/src/pages/MemoDetail.tsx | 6 +- 6 files changed, 353 insertions(+), 121 deletions(-) diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index 585ba6d8..002f31ed 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -1,14 +1,31 @@ -import { create } from "@bufbuild/protobuf"; import { timestampDate } from "@bufbuild/protobuf/wkt"; +import copy from "copy-to-clipboard"; import { isEqual } from "lodash-es"; -import { CheckCircleIcon, ChevronRightIcon, Code2Icon, HashIcon, ImageIcon, LinkIcon, type LucideIcon, Share2Icon } from "lucide-react"; +import { BookmarkIcon, FileTextIcon, ImageIcon, LinkIcon, MapPinIcon, MilestoneIcon, Share2Icon } from "lucide-react"; import { useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; +import toast from "react-hot-toast"; +import { Link } from "react-router-dom"; +import VisibilitySelector from "@/components/MemoEditor/Toolbar/VisibilitySelector"; +import { getLocationDisplayText } from "@/components/MemoMetadata/Location/locationHelpers"; +import { getRelationBuckets, getRelationMemo, type RelationDirection } from "@/components/MemoMetadata/Relation/relationHelpers"; +import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos"; +import UserAvatar from "@/components/UserAvatar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import VisibilityIcon from "@/components/VisibilityIcon"; +import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; +import { useUpdateMemo } from "@/hooks/useMemoQueries"; +import { useUser } from "@/hooks/useUserQueries"; +import i18n from "@/i18n"; import { cn } from "@/lib/utils"; -import { Memo, Memo_PropertySchema } from "@/types/proto/api/v1/memo_service_pb"; -import { type Translations, useTranslate } from "@/utils/i18n"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { State } from "@/types/proto/api/v1/common_pb"; +import { Memo, MemoRelation, Visibility } from "@/types/proto/api/v1/memo_service_pb"; +import { getAttachmentUrl } from "@/utils/attachment"; +import { formatFileSize } from "@/utils/format"; +import { useTranslate } from "@/utils/i18n"; import { extractHeadings } from "@/utils/markdown-manipulation"; +import { convertVisibilityToString } from "@/utils/memo"; import { isSuperUser } from "@/utils/user"; import MemoOutline from "./MemoOutline"; import MemoSharePanel from "./MemoSharePanel"; @@ -19,116 +36,282 @@ interface Props { onShareImageOpen?: () => void; } -interface PropertyBadge { - icon: LucideIcon; - labelKey: Translations; -} +const SectionLabel = ({ label, count }: { label: string; count?: number }) => ( +

+ {label} + {count != null && {count}} +

+); -const SidebarSection = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => ( -
-
-

{label}

- {count != null && ({count})} -
+const Section = ({ label, count, children }: { label: string; count?: number; children: React.ReactNode }) => ( +
+ {children}
); -const PROPERTY_BADGE_CLASSES = - "inline-flex items-center gap-1.5 px-2 py-1 rounded-md border border-border/60 bg-muted/60 text-xs text-muted-foreground"; +/** Quiet icon action for the cluster at the top of the rail. */ +const ActionButton = ({ + label, + active, + onClick, + children, +}: { + label: string; + active?: boolean; + onClick?: () => void; + children: React.ReactNode; +}) => ( + + + } + > + {children} + + {label} + +); -const TAG_BADGE_CLASSES = - "inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer"; +const PropertyLabel = ({ children }: { children: React.ReactNode }) => ( + {children} +); -const SHARE_ACTION_ROW_CLASSES = - "h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5"; +const PROPERTY_VALUE_CLASSES = "min-w-0 flex items-center text-[13px] leading-5 text-foreground/85"; + +const RelationRow = ({ relation, direction, snippet }: { relation: MemoRelation; direction: RelationDirection; snippet: string }) => { + const relatedMemo = getRelationMemo(relation, direction); + if (!relatedMemo) { + return null; + } + const Icon = direction === "referencing" ? LinkIcon : MilestoneIcon; + return ( + + + {snippet} + + ); +}; + +const AttachmentRow = ({ attachment }: { attachment: Attachment }) => { + const isImage = attachment.type.startsWith("image"); + const Icon = isImage ? ImageIcon : FileTextIcon; + const size = Number(attachment.size); + return ( + + + {attachment.filename} + {size > 0 && {formatFileSize(size)}} + + ); +}; const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => { const t = useTranslate(); const currentUser = useCurrentUser(); + const { profile } = useInstance(); + const { mutateAsync: updateMemo } = useUpdateMemo(); const [sharePanelOpen, setSharePanelOpen] = useState(false); - const property = create(Memo_PropertySchema, memo.property || {}); + + const readonly = memo.creator !== currentUser?.name && !isSuperUser(currentUser); + const editable = !readonly && memo.state === State.NORMAL; const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser)); const hasUpdated = !isEqual(memo.createTime, memo.updateTime); - const headings = useMemo(() => extractHeadings(memo.content), [memo.content]); - const propertyBadges = useMemo(() => { - const badges: PropertyBadge[] = []; - if (property.hasLink) badges.push({ icon: LinkIcon, labelKey: "memo.links" }); - if (property.hasTaskList) badges.push({ icon: CheckCircleIcon, labelKey: "memo.to-do" }); - if (property.hasCode) badges.push({ icon: Code2Icon, labelKey: "memo.code" }); - return badges; - }, [property.hasLink, property.hasTaskList, property.hasCode]); + const { data: creator } = useUser(memo.creator, { enabled: !!memo.creator }); + const headings = useMemo(() => extractHeadings(memo.content), [memo.content]); + const { referencing, referenced } = useMemo(() => getRelationBuckets(memo.relations, memo.name), [memo.relations, memo.name]); + const resolvedMemos = useResolvedRelationMemos(memo.relations); + + const createTime = memo.createTime ? timestampDate(memo.createTime) : undefined; + const updateTime = memo.updateTime ? timestampDate(memo.updateTime) : undefined; + const formatDate = (date: Date) => date.toLocaleDateString(i18n.language, { year: "numeric", month: "short", day: "numeric" }); + + const relationSnippet = (relation: MemoRelation, direction: RelationDirection) => { + const relatedMemo = getRelationMemo(relation, direction); + if (!relatedMemo) { + return ""; + } + return relatedMemo.snippet || resolvedMemos[relatedMemo.name]?.snippet || relatedMemo.name; + }; + + const handleTogglePin = async () => { + await updateMemo({ update: { name: memo.name, pinned: !memo.pinned }, updateMask: ["pinned"] }); + }; + + const handleVisibilityChange = async (visibility: Visibility) => { + if (visibility === memo.visibility) { + return; + } + await updateMemo({ update: { name: memo.name, visibility }, updateMask: ["visibility"] }); + }; + + const handleCopyLink = () => { + const host = profile.instanceUrl || window.location.origin; + copy(`${host}/${memo.name}`); + toast.success(t("message.succeed-copy-link")); + }; return ( -