diff --git a/apps/admin-x-framework/src/api/comments.ts b/apps/admin-x-framework/src/api/comments.ts index e1a98c02276..426408726da 100644 --- a/apps/admin-x-framework/src/api/comments.ts +++ b/apps/admin-x-framework/src/api/comments.ts @@ -25,6 +25,7 @@ export type Comment = { title: string; slug: string; url: string; + feature_image?: string; }; count?: { replies?: number; diff --git a/apps/posts/src/views/comments/components/comments-list.tsx b/apps/posts/src/views/comments/components/comments-list.tsx index 62d3a5ef1fd..4dbdf760647 100644 --- a/apps/posts/src/views/comments/components/comments-list.tsx +++ b/apps/posts/src/views/comments/components/comments-list.tsx @@ -7,11 +7,12 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + Avatar, + AvatarFallback, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, LucideIcon, Table, @@ -19,10 +20,16 @@ import { TableCell, TableHead, TableHeader, - TableRow + TableRow, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, + formatNumber, + formatTimestamp } from '@tryghost/shade'; import {Comment, useDeleteComment, useHideComment, useShowComment} from '@tryghost/admin-x-framework/api/comments'; -import {forwardRef, useRef, useState} from 'react'; +import {forwardRef, useEffect, useRef, useState} from 'react'; import {useInfiniteVirtualScroll} from '@components/virtual-table/use-infinite-virtual-scroll'; const SpacerRow = ({height}: { height: number }) => ( @@ -52,13 +59,64 @@ const PlaceholderRow = forwardRef(function PlaceholderRow( function formatDate(dateString: string): string { const date = new Date(dateString); - return new Intl.DateTimeFormat('en-US', { + const formatted = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric' }).format(date); + // Remove comma between day and year (e.g., "Dec 17, 2025" -> "Dec 17 2025") + return formatted.replace(/(\d+),(\s+\d{4})/, '$1$2'); +} + +function ExpandButton({onClick, expanded}: {onClick: () => void; expanded: boolean}) { + return ( + + ); +} + +function CommentContent({item}: {item: Comment}) { + const contentRef = useRef(null); + const [isClamped, setIsClamped] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + const checkIfClamped = () => { + if (contentRef.current) { + // Check if the content is clamped by comparing scrollHeight with clientHeight + setIsClamped(contentRef.current.scrollHeight > contentRef.current.clientHeight); + } + }; + + checkIfClamped(); + // Recheck on window resize + window.addEventListener('resize', checkIfClamped); + return () => window.removeEventListener('resize', checkIfClamped); + }, [item.html]); + + return ( +
+
+
+ {isClamped && ( + setIsExpanded(!isExpanded)} /> + )} +
+
+ ); } function CommentsList({ @@ -74,7 +132,7 @@ function CommentsList({ hasNextPage?: boolean; isFetchingNextPage?: boolean; fetchNextPage: () => void; - onAddFilter?: (field: string, value: string, operator?: string) => void; + onAddFilter: (field: string, value: string, operator?: string) => void; }) { const parentRef = useRef(null); const {visibleItems, spaceBefore, spaceAfter} = useInfiniteVirtualScroll({ @@ -91,10 +149,6 @@ function CommentsList({ const {mutate: deleteComment} = useDeleteComment(); const [commentToDelete, setCommentToDelete] = useState(null); - const handleDeleteClick = (comment: Comment) => { - setCommentToDelete(comment); - }; - const confirmDelete = () => { if (commentToDelete) { deleteComment({id: commentToDelete.id}); @@ -110,13 +164,8 @@ function CommentsList({ > - - Comment - - Author - Post - Date - + + @@ -136,117 +185,177 @@ function CommentsList({ className="grid w-full grid-cols-[1fr_5rem] items-center gap-x-4 p-2 hover:bg-muted/50 md:grid-cols-[1fr_auto_5rem] lg:table-row lg:p-0 [&.group:hover_td]:bg-transparent" data-testid="comment-list-row" > - - {item.status === 'hidden' - ? ( -
- -
+ +
+
+ {item.member?.id ? ( + <> + + + ) : ( + + {item.member?.name || 'Unknown'} + + )} + + + +
+ {item.created_at && ( + + + + + {formatTimestamp(item.created_at)} + + + + {formatDate(item.created_at)} + + + + )} + on + + {item.post?.id && item.post?.title && onAddFilter ? ( + + ) : ( + + Unknown post + + )}
- ) : ( -
- )} - {(item.count?.reports && item.count.reports > 0) ? ( -
- - {item.count.reports} {item.count.reports === 1 ? 'report' : 'reports'} + + {item.status === 'hidden' && ( + <> + +
+ Hidden from members +
+ + )} +
- ) : null} - - - {item.member?.id ? ( - - {item.member.name || 'Unknown'} - - ) : ( - - {item.member?.name || 'Unknown'} - - )} - - - {item.post?.id && item.post?.title ? ( - - {item.post.title} - - ) : ( - - Unknown post - - )} - - - - {item.created_at && - formatDate(item.created_at) - } - - - -
- {item.status === 'published' && ( - - )} - {item.status === 'hidden' && ( - - )} - - - - - - {item.post?.url && ( - - - - View post - - - )} - {item.post?.id && onAddFilter && ( - onAddFilter('post', item.post!.id)}> - - Filter by post - - )} - {item.member?.id && onAddFilter && ( - onAddFilter('author', item.member!.id)}> - - Filter by author - - )} - {item.status !== 'deleted' && <> - - handleDeleteClick(item)} + )} + {item.status === 'hidden' && ( + + )} +
+ + + +
+ + {formatNumber(item.count?.replies)} +
+
+ + Replies + +
+
+ + + + +
+ + {formatNumber(item.count?.likes)} +
+
+ + Likes + +
+
+ + + + +
+ + {formatNumber(item.count?.reports)} +
+
+ + Reports + +
+
+
+ + + + + + {item.post?.url && ( + + + + View post + + + )} + {item.member?.id && ( + + + + View member + + + )} + + +
+ + {item.post?.feature_image ? ( + {item.post.title + ) : null} + ); })} diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js index 27ba9af0244..54a53c32b67 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/comments.js @@ -35,7 +35,8 @@ const postFields = [ 'id', 'uuid', 'title', - 'url' + 'url', + 'feature_image' ]; const countFields = [ diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap index ec9fc398af5..bd7d4ed7c99 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/activity-feed.test.js.snap @@ -22699,7 +22699,7 @@ exports[`Activity Feed API Can filter events by post id 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "18190", + "content-length": "18298", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -22871,7 +22871,7 @@ exports[`Activity Feed API Filter splitting Can use NQL OR for type only 2: [hea Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "5352", + "content-length": "5460", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -23987,7 +23987,7 @@ exports[`Activity Feed API Returns comments in activity feed 2: [headers] 1`] = Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1458", + "content-length": "1566", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/comments.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/comments.test.js.snap index acb48b92273..05f39859a89 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/comments.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/comments.test.js.snap @@ -25,6 +25,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -71,6 +72,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "https://example.com/super_photo.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "HTML Ipsum", "url": Any, @@ -100,6 +102,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -146,6 +149,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -192,6 +196,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -238,6 +243,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -267,6 +273,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -313,6 +320,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -359,6 +367,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -405,6 +414,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -451,6 +461,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -480,6 +491,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -526,6 +538,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -555,6 +568,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -601,6 +615,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -647,6 +662,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any, @@ -676,6 +692,7 @@ Object { }, "parent_id": Nullable, "post": Object { + "feature_image": "http://127.0.0.1:2369/content/images/2018/hey.jpg", "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, "title": "Ghostly Kitchen Sink", "url": Any,