diff --git a/.gitignore b/.gitignore index 11bfe35a6d2..ac39fde0e94 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,4 @@ yalc.lock .env.tinybird .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md +.worktrees/ diff --git a/apps/admin-x-framework/src/api/comments.ts b/apps/admin-x-framework/src/api/comments.ts new file mode 100644 index 00000000000..7621e709601 --- /dev/null +++ b/apps/admin-x-framework/src/api/comments.ts @@ -0,0 +1,132 @@ +import {Meta, createInfiniteQuery, createMutation} from '../utils/api/hooks'; +import {InfiniteData} from '@tanstack/react-query'; + +export type CommentMember = { + id: string; + name: string | null; + email: string; + avatar_image: string | null; + commenting_enabled?: boolean; +}; + +export type CommentPost = { + id: string; + title: string; + slug: string; + url: string; +}; + +export type Comment = { + id: string; + html: string; + status: 'published' | 'hidden' | 'deleted'; + created_at: string; + member_id: string | null; + member?: CommentMember | null; + post_id: string; + post?: CommentPost; + parent_id: string | null; + hidden_at_ban?: boolean; + count?: { + replies?: number; + likes?: number; + }; +}; + +export interface CommentsResponseType { + meta?: Meta; + comments: Comment[]; +} + +export interface BulkEditResponse { + bulk: { + action: string; + meta: { + stats: { + successful: number; + unsuccessful: number; + }; + errors: unknown[]; + }; + }; +} + +const dataType = 'CommentsResponseType'; + +export const useBrowseComments = createInfiniteQuery({ + dataType, + path: '/comments/', + defaultSearchParams: { + include: 'member,post', + limit: '30', + order: 'created_at desc' + }, + defaultNextPageParams: (lastPage, params) => { + if (!lastPage.meta?.pagination?.next) { + return undefined; + } + return { + ...params, + page: lastPage.meta.pagination.next.toString() + }; + }, + returnData: (originalData) => { + const {pages} = originalData as InfiniteData; + const comments = pages.flatMap(page => page.comments); + const meta = pages[pages.length - 1].meta; + return { + comments, + meta, + isEnd: meta ? !meta.pagination.next : true + }; + } +}); + +export const useHideComment = createMutation({ + method: 'PUT', + path: id => `/comments/${id}/`, + body: id => ({ + comments: [{id, status: 'hidden'}] + }), + invalidateQueries: {dataType} +}); + +export const useShowComment = createMutation({ + method: 'PUT', + path: id => `/comments/${id}/`, + body: id => ({ + comments: [{id, status: 'published'}] + }), + invalidateQueries: {dataType} +}); + +export const useDeleteComment = createMutation({ + method: 'PUT', + path: id => `/comments/${id}/`, + body: id => ({ + comments: [{id, status: 'deleted'}] + }), + invalidateQueries: {dataType} +}); + +export interface BulkEditPayload { + filter?: string; + action: 'hide' | 'show' | 'delete'; +} + +export const useBulkEditComments = createMutation({ + method: 'PUT', + path: () => '/comments/bulk/', + searchParams: payload => { + if (!payload.filter) { + return undefined; + } + return {filter: payload.filter}; + }, + body: payload => ({ + bulk: { + action: payload.action + } + }), + invalidateQueries: {dataType} +}); diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index 948a59cd8e6..5ebe7286f58 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -1,7 +1,11 @@ -import {Meta, createQuery} from '../utils/api/hooks'; +import {Meta, createQuery, createMutation} from '../utils/api/hooks'; export type Member = { id: string; + name?: string | null; + email?: string; + avatar_image?: string | null; + commenting_enabled?: boolean; }; export interface MembersResponseType { @@ -15,3 +19,18 @@ export const useBrowseMembers = createQuery({ dataType, path: '/members/' }); + +export const useBanMemberFromComments = createMutation({ + method: 'POST', + path: memberId => `/members/${memberId}/ban-from-comments/`, + invalidateQueries: {dataType} +}); + +export const useUnbanMemberFromComments = createMutation({ + method: 'POST', + path: ({memberId}) => `/members/${memberId}/unban-from-comments/`, + body: ({restoreComments = true}) => ({ + restore_comments: restoreComments + }), + invalidateQueries: {dataType} +}); diff --git a/apps/admin-x-framework/src/utils/api/hooks.ts b/apps/admin-x-framework/src/utils/api/hooks.ts index 347f714cb88..09c07c764d6 100644 --- a/apps/admin-x-framework/src/utils/api/hooks.ts +++ b/apps/admin-x-framework/src/utils/api/hooks.ts @@ -149,7 +149,7 @@ interface MutationOptions extends Omit string; headers?: Record; body?: (payload: Payload) => FormData | object; - searchParams?: (payload: Payload) => { [key: string]: string; }; + searchParams?: (payload: Payload) => { [key: string]: string; } | undefined; invalidateQueries?: { dataType: string; } | { filters?: InvalidateQueryFilters, options?: InvalidateOptions, diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index 25f39a76fc4..36a8a8a326a 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -56,6 +56,10 @@ const features: Feature[] = [{ title: 'Updated theme translation (beta)', description: 'Enable theme translation using i18next instead of the old translation package.', flag: 'themeTranslation' +}, { + title: 'Comment moderation', + description: 'Enable the Comments moderation page and member commenting controls', + flag: 'commentModeration' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 5e95613f3f5..26844bb10fb 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useMemo } from "react" import { Button, @@ -10,6 +10,7 @@ import { } from "@tryghost/shade" import { useCurrentUser } from "@tryghost/admin-x-framework/api/current-user"; import { canManageMembers, canManageTags } from "@tryghost/admin-x-framework/api/users"; +import { useBrowseSettings, getSettingValue } from "@tryghost/admin-x-framework/api/settings"; import { NavMenuItem } from "./nav-menu-item"; import NavSubMenu from "./nav-sub-menu"; import { useMemberCount } from "./hooks/use-member-count"; @@ -19,6 +20,7 @@ import { useEmberRouting } from "@/ember-bridge"; function NavContent({ ...props }: React.ComponentProps) { const { data: currentUser } = useCurrentUser(); + const { data: settingsData } = useBrowseSettings(); const [postsExpanded, setPostsExpanded] = useNavigationExpanded('posts'); const memberCount = useMemberCount(); const routing = useEmberRouting(); @@ -26,6 +28,15 @@ function NavContent({ ...props }: React.ComponentProps) { const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); + const showComments = useMemo(() => { + if (!showMembers) { + return false; + } + const labsJSON = getSettingValue(settingsData?.settings, 'labs') || '{}'; + const labs = JSON.parse(labsJSON); + return labs.commentModeration === true; + }, [showMembers, settingsData?.settings]); + return ( @@ -136,6 +147,18 @@ function NavContent({ ...props }: React.ComponentProps) { )} )} + + {showComments && ( + + + + Comments + + + )} diff --git a/apps/comments-ui/src/components/content/comment.tsx b/apps/comments-ui/src/components/content/comment.tsx index 28c206a0a86..93dbdd6de0c 100644 --- a/apps/comments-ui/src/components/content/comment.tsx +++ b/apps/comments-ui/src/components/content/comment.tsx @@ -165,11 +165,9 @@ const UnpublishedComment: React.FC = ({comment, openEdi : ; const hasReplies = comment.replies && comment.replies.length > 0; - const notPublishedMessage = comment.status === 'hidden' ? - t('This comment has been hidden.') : - comment.status === 'deleted' ? - t('This comment has been removed.') : - ''; + const notPublishedMessage = (comment.status === 'hidden' || comment.status === 'deleted') ? + t('[Comment removed by moderator]') : + ''; // currently a reply-to-reply form is displayed inside the top-level PublishedComment component // so we need to check for a match of either the comment id or the parent id diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts index f7197927b7f..77996499459 100644 --- a/apps/comments-ui/test/e2e/actions.test.ts +++ b/apps/comments-ui/test/e2e/actions.test.ts @@ -699,7 +699,7 @@ test.describe('Actions', async () => { await deleteComment(page, frame, commentToDelete); await expect(frame.getByTestId('comment-component')).toHaveCount(2); - await expect(frame.getByText('This comment has been removed')).toBeVisible(); + await expect(frame.getByText('[Comment removed by moderator]')).toBeVisible(); await expect(frame.getByTestId('replies-line')).toBeVisible(); }); diff --git a/apps/comments-ui/test/e2e/content.test.ts b/apps/comments-ui/test/e2e/content.test.ts index 63c632a8523..fd213b32449 100644 --- a/apps/comments-ui/test/e2e/content.test.ts +++ b/apps/comments-ui/test/e2e/content.test.ts @@ -97,10 +97,10 @@ test.describe('Deleted and Hidden Content', async () => { }); await expect (frame.getByText('This is comment 2')).not.toBeVisible(); - await expect (frame.getByText('This comment has been hidden')).toBeVisible(); + await expect (frame.getByText('[Comment removed by moderator]').first()).toBeVisible(); await expect (frame.getByText('This is comment 4')).not.toBeVisible(); - await expect (frame.getByText('This comment has been removed')).toBeVisible(); + await expect (frame.getByText('[Comment removed by moderator]').nth(1)).toBeVisible(); }); test('hides replies that are hidden or deleted', async ({page}) => { @@ -132,8 +132,8 @@ test.describe('Deleted and Hidden Content', async () => { await expect (frame.getByText('This is reply 1')).toBeVisible(); await expect (frame.getByText('This is reply 2')).not.toBeVisible(); - // parent comment is hidden but shows text - await expect (frame.getByText('This comment has been hidden')).toBeVisible(); + // parent comment is hidden but shows tombstone text + await expect (frame.getByText('[Comment removed by moderator]')).toBeVisible(); }); }); diff --git a/apps/posts/src/routes.tsx b/apps/posts/src/routes.tsx index 2ddc6fd7547..67f0a009822 100644 --- a/apps/posts/src/routes.tsx +++ b/apps/posts/src/routes.tsx @@ -1,3 +1,4 @@ +import Comments from '@views/Comments/comments'; import Growth from '@views/PostAnalytics/Growth/growth'; import Newsletter from '@views/PostAnalytics/Newsletter/newsletter'; import Overview from '@views/PostAnalytics/Overview/overview'; @@ -62,6 +63,10 @@ export const routes: RouteObject[] = [ } ] }, + { + path: 'comments', + element: + }, // Error handling { diff --git a/apps/posts/src/views/Comments/comments.tsx b/apps/posts/src/views/Comments/comments.tsx new file mode 100644 index 00000000000..3bd10b2483e --- /dev/null +++ b/apps/posts/src/views/Comments/comments.tsx @@ -0,0 +1,432 @@ +import React, {useState, useCallback, useMemo, useEffect} from 'react'; +import BulkActionsBar from './components/bulk-actions-bar'; +import CommentsContent from './components/comments-content'; +import CommentsHeader from './components/comments-header'; +import CommentsLayout from './components/comments-layout'; +import CommentsList from './components/comments-list'; +import {useQueryClient} from '@tanstack/react-query'; +import {Button, EmptyIndicator, LoadingIndicator, LucideIcon, toast} from '@tryghost/shade'; +import {useBrowseComments, useHideComment, useShowComment, useDeleteComment, useBulkEditComments} from '@tryghost/admin-x-framework/api/comments'; +import {useBanMemberFromComments, useUnbanMemberFromComments} from '@tryghost/admin-x-framework/api/members'; +import {useLocation, useNavigate} from '@tryghost/admin-x-framework'; + +const Comments: React.FC = () => { + const navigate = useNavigate(); + const {search} = useLocation(); + const queryClient = useQueryClient(); + const qs = new URLSearchParams(search); + + const statusFilter = qs.get('status') ?? 'all'; + const sortOrder = qs.get('sort') ?? 'desc'; + const memberFilter = qs.get('member') ?? ''; + const commentFilter = qs.get('comment') ?? ''; + + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isAllMatchingSelected, setIsAllMatchingSelected] = useState(false); + + // Build filter based on status + const memberFilterExpression = useMemo(() => { + if (!memberFilter) { + return undefined; + } + const trimmed = memberFilter.trim(); + if (/^[a-f0-9]{24}$/i.test(trimmed)) { + return `member_id:'${trimmed}'`; + } + return undefined; + }, [memberFilter]); + + const commentFilterExpression = useMemo(() => { + if (!commentFilter) { + return undefined; + } + const trimmed = commentFilter.trim(); + if (/^[a-f0-9]{24}$/i.test(trimmed)) { + return `id:'${trimmed}'`; + } + return undefined; + }, [commentFilter]); + + const filter = useMemo(() => { + const clauses = [] as string[]; + if (statusFilter !== 'all') { + clauses.push(`status:${statusFilter}`); + } + if (memberFilterExpression) { + clauses.push(memberFilterExpression); + } + if (commentFilterExpression) { + clauses.push(commentFilterExpression); + } + if (!clauses.length) { + return undefined; + } + return clauses.join('+'); + }, [statusFilter, memberFilterExpression, commentFilterExpression]); + + const { + data, + isError, + isLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage + } = useBrowseComments({ + searchParams: { + ...(filter && {filter}), + order: `created_at ${sortOrder}`, + include: 'member,post', + limit: '30', + include_nested: 'true' + } + }); + + const comments = data?.comments ?? []; + const totalComments = data?.meta?.pagination?.total ?? 0; + + const hideComment = useHideComment(); + const showComment = useShowComment(); + const deleteComment = useDeleteComment(); + const bulkEditComments = useBulkEditComments(); + const banMember = useBanMemberFromComments(); + const unbanMember = useUnbanMemberFromComments(); + + const handleStatusFilterChange = useCallback((value: string) => { + const params = new URLSearchParams(); + if (value !== 'all') { + params.set('status', value); + } + if (sortOrder !== 'desc') { + params.set('sort', sortOrder); + } + if (memberFilter) { + params.set('member', memberFilter); + } + if (commentFilter) { + params.set('comment', commentFilter); + } + navigate(`/comments${params.toString() ? `?${params.toString()}` : ''}`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + }, [navigate, sortOrder, memberFilter, commentFilter]); + + const handleSortOrderChange = useCallback((value: string) => { + const params = new URLSearchParams(); + if (statusFilter !== 'all') { + params.set('status', statusFilter); + } + if (value !== 'desc') { + params.set('sort', value); + } + if (memberFilter) { + params.set('member', memberFilter); + } + if (commentFilter) { + params.set('comment', commentFilter); + } + navigate(`/comments${params.toString() ? `?${params.toString()}` : ''}`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + }, [navigate, statusFilter, memberFilter, commentFilter]); + + const handleMemberFilterChange = useCallback((value: string) => { + const trimmed = value.trim(); + const isValidId = trimmed === '' || /^[a-f0-9]{24}$/i.test(trimmed); + const params = new URLSearchParams(); + if (statusFilter !== 'all') { + params.set('status', statusFilter); + } + if (sortOrder !== 'desc') { + params.set('sort', sortOrder); + } + if (trimmed && isValidId) { + params.set('member', trimmed); + } + if (commentFilter) { + params.set('comment', commentFilter); + } + navigate(`/comments${params.toString() ? `?${params.toString()}` : ''}`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + }, [navigate, statusFilter, sortOrder, commentFilter]); + + const handleCommentFilterChange = useCallback((value: string) => { + const trimmed = value.trim(); + const isValidId = trimmed === '' || /^[a-f0-9]{24}$/i.test(trimmed); + const params = new URLSearchParams(); + if (statusFilter !== 'all') { + params.set('status', statusFilter); + } + if (sortOrder !== 'desc') { + params.set('sort', sortOrder); + } + if (memberFilter) { + params.set('member', memberFilter); + } + if (trimmed && isValidId) { + params.set('comment', trimmed); + } + navigate(`/comments${params.toString() ? `?${params.toString()}` : ''}`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + }, [navigate, statusFilter, sortOrder, memberFilter]); + + const handleFilterByMember = useCallback((memberId: string) => { + if (!memberId) { + return; + } + handleMemberFilterChange(memberId); + }, [handleMemberFilterChange]); + + const handleFilterByComment = useCallback((commentId: string) => { + if (!commentId) { + return; + } + handleCommentFilterChange(commentId); + }, [handleCommentFilterChange]); + + const handleMemberNavigate = useCallback((memberId: string) => { + if (!memberId) { + return; + } + navigate(`/members/${memberId}`, {crossApp: true}); + }, [navigate]); + + const handleHideComment = useCallback(async (id: string) => { + try { + await hideComment.mutateAsync(id); + toast.success('Comment hidden'); + } catch { + toast.error('Failed to hide comment'); + } + }, [hideComment]); + + const handleShowComment = useCallback(async (id: string) => { + try { + await showComment.mutateAsync(id); + toast.success('Comment shown'); + } catch { + toast.error('Failed to show comment'); + } + }, [showComment]); + + const handleDeleteComment = useCallback(async (id: string) => { + try { + await deleteComment.mutateAsync(id); + toast.success('Comment deleted'); + } catch { + toast.error('Failed to delete comment'); + } + }, [deleteComment]); + + const handleBanMember = useCallback(async (memberId: string) => { + try { + await banMember.mutateAsync(memberId); + await queryClient.invalidateQueries(['CommentsResponseType']); + toast.success('Member banned from commenting'); + } catch (error) { + console.error('Ban member error:', error); + toast.error('Failed to ban member'); + } + }, [banMember, queryClient]); + + const handleUnbanMember = useCallback(async (memberId: string) => { + try { + await unbanMember.mutateAsync({memberId, restoreComments: true}); + await queryClient.invalidateQueries(['CommentsResponseType']); + toast.success('Member unbanned from commenting'); + } catch (error) { + console.error('Unban member error:', error); + toast.error('Failed to unban member'); + } + }, [unbanMember, queryClient]); + + const handleSelectAllMatching = useCallback(() => { + if (data?.comments) { + setSelectedIds(new Set(data.comments.map(c => c.id))); + } + setIsAllMatchingSelected(true); + }, [data?.comments]); + + const handleSelectionChange = useCallback((ids: Set) => { + setIsAllMatchingSelected(false); + setSelectedIds(new Set(ids)); + }, []); + + useEffect(() => { + if (!isAllMatchingSelected || !data?.comments) { + return; + } + + setSelectedIds((prev) => { + const currentIds = new Set(data.comments.map(c => c.id)); + if (prev.size === currentIds.size) { + let isSame = true; + currentIds.forEach((id) => { + if (!prev.has(id)) { + isSame = false; + } + }); + if (isSame) { + return prev; + } + } + return currentIds; + }); + }, [isAllMatchingSelected, data?.comments]); + + const handleClearSelection = useCallback(() => { + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + }, []); + + const getSelectionFilter = useCallback(() => { + if (isAllMatchingSelected) { + return filter ?? 'status:[published,hidden,deleted]'; + } + + if (selectedIds.size === 0) { + return null; + } + + return `id:[${Array.from(selectedIds).map(id => `'${id}'`).join(',')}]`; + }, [isAllMatchingSelected, filter, selectedIds]); + + const handleBulkHide = useCallback(async () => { + const selectionCount = isAllMatchingSelected ? totalComments : selectedIds.size; + if (selectionCount === 0) { + return; + } + try { + const filterStr = getSelectionFilter(); + if (filterStr === null) { + return; + } + await bulkEditComments.mutateAsync({filter: filterStr, action: 'hide'}); + await queryClient.invalidateQueries(['CommentsResponseType']); + toast.success(`${selectionCount} ${selectionCount === 1 ? 'comment' : 'comments'} hidden`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + } catch { + toast.error('Failed to hide comments'); + } + }, [selectedIds, bulkEditComments, getSelectionFilter, isAllMatchingSelected, totalComments]); + + const handleBulkShow = useCallback(async () => { + const selectionCount = isAllMatchingSelected ? totalComments : selectedIds.size; + if (selectionCount === 0) { + return; + } + try { + const filterStr = getSelectionFilter(); + if (filterStr === null) { + return; + } + await bulkEditComments.mutateAsync({filter: filterStr, action: 'show'}); + await queryClient.invalidateQueries(['CommentsResponseType']); + toast.success(`${selectionCount} ${selectionCount === 1 ? 'comment' : 'comments'} shown`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + } catch { + toast.error('Failed to show comments'); + } + }, [selectedIds, bulkEditComments, getSelectionFilter, isAllMatchingSelected, totalComments]); + + const handleBulkDelete = useCallback(async () => { + const selectionCount = isAllMatchingSelected ? totalComments : selectedIds.size; + if (selectionCount === 0) { + return; + } + try { + const filterStr = getSelectionFilter(); + if (filterStr === null) { + return; + } + await bulkEditComments.mutateAsync({filter: filterStr, action: 'delete'}); + await queryClient.invalidateQueries(['CommentsResponseType']); + toast.success(`${selectionCount} ${selectionCount === 1 ? 'comment' : 'comments'} deleted`); + setSelectedIds(new Set()); + setIsAllMatchingSelected(false); + } catch { + toast.error('Failed to delete comments'); + } + }, [selectedIds, bulkEditComments, getSelectionFilter, isAllMatchingSelected, totalComments]); + + const selectedCount = isAllMatchingSelected ? totalComments : selectedIds.size; + + return ( + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

+ Error loading comments +

+

+ Please reload the page to try again +

+ +
+ ) : comments.length === 0 ? ( +
+ + + +
+ ) : ( + <> + + + + )} +
+
+ ); +}; + +export default Comments; diff --git a/apps/posts/src/views/Comments/components/bulk-actions-bar.tsx b/apps/posts/src/views/Comments/components/bulk-actions-bar.tsx new file mode 100644 index 00000000000..0c38568d36a --- /dev/null +++ b/apps/posts/src/views/Comments/components/bulk-actions-bar.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + LucideIcon +} from '@tryghost/shade'; + +interface BulkActionsBarProps { + selectedCount: number; + totalCount: number; + onClearSelection: () => void; + onBulkHide: () => void; + onBulkShow: () => void; + onBulkDelete: () => void; + isSelectingAllMatching: boolean; +} + +const BulkActionsBar: React.FC = ({ + selectedCount, + totalCount, + onClearSelection, + onBulkHide, + onBulkShow, + onBulkDelete, + isSelectingAllMatching +}) => { + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false); + const [showHideDialog, setShowHideDialog] = React.useState(false); + + if (selectedCount === 0) { + return null; + } + + return ( + <> +
+
+
+ + {selectedCount} {selectedCount === 1 ? 'comment' : 'comments'} selected + {isSelectingAllMatching && totalCount > 0 && ' (entire list)'} + + +
+
+ + + +
+
+
+ + + + + Hide {selectedCount} {selectedCount === 1 ? 'comment' : 'comments'}? + + Hidden comments will show as "[Comment removed by moderator]" in threaded comment views. + + + + Cancel + { + onBulkHide(); + setShowHideDialog(false); + }}> + Hide comments + + + + + + + + + Delete {selectedCount} {selectedCount === 1 ? 'comment' : 'comments'}? + + Deleted comments will be permanently removed and cannot be restored. + + + + Cancel + { + onBulkDelete(); + setShowDeleteDialog(false); + }}> + Delete comments + + + + + + ); +}; + +export default BulkActionsBar; diff --git a/apps/posts/src/views/Comments/components/comments-content.tsx b/apps/posts/src/views/Comments/components/comments-content.tsx new file mode 100644 index 00000000000..7c84efae1d1 --- /dev/null +++ b/apps/posts/src/views/Comments/components/comments-content.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import {cn} from '@tryghost/shade'; + +const CommentsContent: React.FC> = ({children, className, ...props}) => { + return ( +
+ {children} +
+ ); +}; + +export default CommentsContent; diff --git a/apps/posts/src/views/Comments/components/comments-header.tsx b/apps/posts/src/views/Comments/components/comments-header.tsx new file mode 100644 index 00000000000..ecf865541df --- /dev/null +++ b/apps/posts/src/views/Comments/components/comments-header.tsx @@ -0,0 +1,173 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import {Button, Header, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, ToggleGroup, ToggleGroupItem} from '@tryghost/shade'; +import {Link} from '@tryghost/admin-x-framework'; + +interface CommentsHeaderProps { + statusFilter: string; + onStatusFilterChange: (value: string) => void; + sortOrder: string; + onSortOrderChange: (value: string) => void; + memberFilter: string; + onMemberFilterChange: (value: string) => void; + commentFilter: string; + onCommentFilterChange: (value: string) => void; +} + +const CommentsHeader: React.FC = ({ + statusFilter, + onStatusFilterChange, + sortOrder, + onSortOrderChange, + memberFilter, + onMemberFilterChange, + commentFilter, + onCommentFilterChange +}) => { + const [inputValue, setInputValue] = useState(memberFilter); + const [error, setError] = useState(null); + const [commentInput, setCommentInput] = useState(commentFilter); + const [commentError, setCommentError] = useState(null); + + const isValidMemberId = useCallback((value: string) => /^[a-f0-9]{24}$/i.test(value), []); + + useEffect(() => { + setInputValue(memberFilter); + setError(null); + }, [memberFilter]); + + useEffect(() => { + setCommentInput(commentFilter); + setCommentError(null); + }, [commentFilter]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = inputValue.trim(); + if (!trimmed) { + setError(null); + onMemberFilterChange(''); + return; + } + if (!isValidMemberId(trimmed)) { + setError('Enter a valid 24-character member ID'); + return; + } + setError(null); + onMemberFilterChange(trimmed); + }; + + const handleClear = () => { + setInputValue(''); + setError(null); + onMemberFilterChange(''); + }; + + const handleCommentSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const trimmed = commentInput.trim(); + if (!trimmed) { + setCommentError(null); + onCommentFilterChange(''); + return; + } + if (!isValidMemberId(trimmed)) { + setCommentError('Enter a valid 24-character comment ID'); + return; + } + setCommentError(null); + onCommentFilterChange(trimmed); + }; + + const handleCommentClear = () => { + setCommentInput(''); + setCommentError(null); + onCommentFilterChange(''); + }; + + return ( +
+ Comments + + + + value && onStatusFilterChange(value)}> + + + All + + + + + Published + + + + + Hidden + + + + + + + + +
+ setInputValue(event.target.value)} + /> +
+ + {memberFilter && ( + + )} +
+ {error && ( + {error} + )} +
+
+ +
+ setCommentInput(event.target.value)} + /> +
+ + {commentFilter && ( + + )} +
+ {commentError && ( + {commentError} + )} +
+
+
+
+ ); +}; + +export default CommentsHeader; diff --git a/apps/posts/src/views/Comments/components/comments-layout.tsx b/apps/posts/src/views/Comments/components/comments-layout.tsx new file mode 100644 index 00000000000..69e14ffef43 --- /dev/null +++ b/apps/posts/src/views/Comments/components/comments-layout.tsx @@ -0,0 +1,16 @@ +import MainLayout from '@components/layout/main-layout'; +import React from 'react'; + +const CommentsLayout: React.FC> = ({children}) => { + return ( + +
+
+ {children} +
+
+
+ ); +}; + +export default CommentsLayout; diff --git a/apps/posts/src/views/Comments/components/comments-list.tsx b/apps/posts/src/views/Comments/components/comments-list.tsx new file mode 100644 index 00000000000..67b6874182a --- /dev/null +++ b/apps/posts/src/views/Comments/components/comments-list.tsx @@ -0,0 +1,344 @@ +import { + Avatar, + AvatarFallback, + Badge, + Button, + Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + LucideIcon, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + formatMemberName, + getMemberInitials, + stringToHslColor +} from '@tryghost/shade'; +import {Comment} from '@tryghost/admin-x-framework/api/comments'; +import {forwardRef, useRef} from 'react'; +import {useInfiniteVirtualScroll} from '../../Tags/components/VirtualTable/use-infinite-virtual-scroll'; + +const SpacerRow = ({height}: { height: number }) => ( + + + +); + +const PlaceholderRow = forwardRef(function PlaceholderRow( + props, + ref +) { + return ( +