feat: show inline comment preview in list view
Add a comment preview section below memo cards in list view, displaying up to 3 comment snippets with a "View all" link. Removes the old comment count icon from the memo header in favor of this richer inline display. Comment preview is hidden in memo detail view.
This commit is contained in:
parent
3e4c052f44
commit
3a5d3c8ff9
4 changed files with 93 additions and 40 deletions
|
|
@ -1,12 +1,14 @@
|
||||||
import { memo, useMemo, useRef, useState } from "react";
|
import { memo, useMemo, useRef, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { useUser } from "@/hooks/useUserQueries";
|
import { useUser } from "@/hooks/useUserQueries";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { State } from "@/types/proto/api/v1/common_pb";
|
import { State } from "@/types/proto/api/v1/common_pb";
|
||||||
|
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||||
import { isSuperUser } from "@/utils/user";
|
import { isSuperUser } from "@/utils/user";
|
||||||
import MemoEditor from "../MemoEditor";
|
import MemoEditor from "../MemoEditor";
|
||||||
import PreviewImageDialog from "../PreviewImageDialog";
|
import PreviewImageDialog from "../PreviewImageDialog";
|
||||||
import { MemoBody, MemoHeader } from "./components";
|
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
|
||||||
import { MEMO_CARD_BASE_CLASSES } from "./constants";
|
import { MEMO_CARD_BASE_CLASSES } from "./constants";
|
||||||
import { useImagePreview, useMemoActions, useMemoHandlers } from "./hooks";
|
import { useImagePreview, useMemoActions, useMemoHandlers } from "./hooks";
|
||||||
import { MemoViewContext } from "./MemoViewContext";
|
import { MemoViewContext } from "./MemoViewContext";
|
||||||
|
|
@ -42,6 +44,13 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||||
openPreview,
|
openPreview,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`);
|
||||||
|
const commentAmount = memoData.relations.filter(
|
||||||
|
(r) => r.type === MemoRelation_Type.COMMENT && r.relatedMemo?.name === memoData.name,
|
||||||
|
).length;
|
||||||
|
const showCommentPreview = !isInMemoDetailPage && commentAmount > 0;
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
memo: memoData,
|
memo: memoData,
|
||||||
|
|
@ -69,32 +78,47 @@ const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const article = (
|
||||||
|
<article
|
||||||
|
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
|
||||||
|
ref={cardRef}
|
||||||
|
tabIndex={readonly ? -1 : 0}
|
||||||
|
>
|
||||||
|
<MemoHeader
|
||||||
|
showCreator={props.showCreator}
|
||||||
|
showVisibility={props.showVisibility}
|
||||||
|
showPinned={props.showPinned}
|
||||||
|
onEdit={openEditor}
|
||||||
|
onGotoDetail={handleGotoMemoDetailPage}
|
||||||
|
onUnpin={unpinMemo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MemoBody
|
||||||
|
compact={props.compact}
|
||||||
|
onContentClick={handleMemoContentClick}
|
||||||
|
onContentDoubleClick={handleMemoContentDoubleClick}
|
||||||
|
onToggleNsfwVisibility={toggleNsfwVisibility}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PreviewImageDialog
|
||||||
|
open={previewState.open}
|
||||||
|
onOpenChange={setPreviewOpen}
|
||||||
|
imgUrls={previewState.urls}
|
||||||
|
initialIndex={previewState.index}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemoViewContext.Provider value={contextValue}>
|
<MemoViewContext.Provider value={contextValue}>
|
||||||
<article className={cn(MEMO_CARD_BASE_CLASSES, className)} ref={cardRef} tabIndex={readonly ? -1 : 0}>
|
{showCommentPreview ? (
|
||||||
<MemoHeader
|
<div className="mb-2">
|
||||||
showCreator={props.showCreator}
|
{article}
|
||||||
showVisibility={props.showVisibility}
|
<MemoCommentListView />
|
||||||
showPinned={props.showPinned}
|
</div>
|
||||||
onEdit={openEditor}
|
) : (
|
||||||
onGotoDetail={handleGotoMemoDetailPage}
|
article
|
||||||
onUnpin={unpinMemo}
|
)}
|
||||||
/>
|
|
||||||
|
|
||||||
<MemoBody
|
|
||||||
compact={props.compact}
|
|
||||||
onContentClick={handleMemoContentClick}
|
|
||||||
onContentDoubleClick={handleMemoContentDoubleClick}
|
|
||||||
onToggleNsfwVisibility={toggleNsfwVisibility}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PreviewImageDialog
|
|
||||||
open={previewState.open}
|
|
||||||
onOpenChange={setPreviewOpen}
|
|
||||||
imgUrls={previewState.urls}
|
|
||||||
initialIndex={previewState.index}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
</MemoViewContext.Provider>
|
</MemoViewContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ArrowUpRightIcon } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useMemoComments } from "@/hooks/useMemoQueries";
|
||||||
|
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
||||||
|
|
||||||
|
const MemoCommentListView: React.FC = () => {
|
||||||
|
const { memo } = useMemoViewContext();
|
||||||
|
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
|
||||||
|
|
||||||
|
const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0 });
|
||||||
|
const comments = data?.memos ?? [];
|
||||||
|
|
||||||
|
if (isInMemoDetailPage || commentAmount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayedComments = comments.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-t-0 border-border rounded-b-xl px-4 pt-2 pb-3 flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs text-muted-foreground">Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}</span>
|
||||||
|
<Link
|
||||||
|
to={`/${memo.name}#comments`}
|
||||||
|
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground hover:underline underline-offset-2 transition-colors"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowUpRightIcon className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{displayedComments.map((comment) => (
|
||||||
|
<div key={comment.name} className="bg-muted/60 rounded-md px-2 py-1 text-xs text-muted-foreground truncate leading-relaxed">
|
||||||
|
{comment.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MemoCommentListView;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||||
import { BookmarkIcon, MessageCircleMoreIcon } from "lucide-react";
|
import { BookmarkIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
@ -20,8 +20,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||||
|
|
||||||
const { memo, creator, currentUser, parentPage, isArchived, readonly } = useMemoViewContext();
|
const { memo, creator, currentUser, isArchived, readonly } = useMemoViewContext();
|
||||||
const { isInMemoDetailPage, commentAmount, relativeTimeFormat } = useMemoViewDerived();
|
const { relativeTimeFormat } = useMemoViewDerived();
|
||||||
|
|
||||||
const displayTime = isArchived ? (
|
const displayTime = isArchived ? (
|
||||||
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
|
(memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language)
|
||||||
|
|
@ -52,18 +52,6 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isInMemoDetailPage && commentAmount > 0 && (
|
|
||||||
<Link
|
|
||||||
className={cn("flex flex-row justify-start items-center rounded-md px-1 hover:opacity-80 gap-0.5")}
|
|
||||||
to={`/${memo.name}#comments`}
|
|
||||||
viewTransition
|
|
||||||
state={{ from: parentPage }}
|
|
||||||
>
|
|
||||||
<MessageCircleMoreIcon className="w-4 h-4 mx-auto text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">{commentAmount}</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
|
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { default as MemoBody } from "./MemoBody";
|
export { default as MemoBody } from "./MemoBody";
|
||||||
|
export { default as MemoCommentListView } from "./MemoCommentListView";
|
||||||
export { default as MemoHeader } from "./MemoHeader";
|
export { default as MemoHeader } from "./MemoHeader";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue