Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/admin-x-framework/src/api/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Comment = {
id: string;
html: string | null;
status: 'published' | 'hidden' | 'deleted';
pinned: boolean;
created_at: string;
updated_at: string;
post_id: string;
Expand Down Expand Up @@ -138,6 +139,34 @@ export const useDeleteComment = createMutation<CommentsResponseType, {id: string
}
});

export const usePinComment = createMutation<CommentsResponseType, {id: string}>({
method: 'PUT',
path: ({id}) => `/comments/${id}/`,
body: ({id}) => ({
comments: [{
id,
pinned: true
}]
}),
invalidateQueries: {
dataType
}
});

export const useUnpinComment = createMutation<CommentsResponseType, {id: string}>({
method: 'PUT',
path: ({id}) => `/comments/${id}/`,
body: ({id}) => ({
comments: [{
id,
pinned: false
}]
}),
invalidateQueries: {
dataType
}
});

export const useCommentReplies = createQueryWithId<CommentsResponseType>({
dataType,
path: (id: string) => `/comments/${id}/replies/`,
Expand Down
6 changes: 5 additions & 1 deletion apps/admin/src/layout/app-sidebar/nav-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from "react"
import {SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuBadge} from "@tryghost/shade/components"
import {formatNumber, LucideIcon} from "@tryghost/shade/utils"
import { useCurrentUser } from "@tryghost/admin-x-framework/api/current-user";
import {getSettingValue, useBrowseSettings} from "@tryghost/admin-x-framework/api/settings";
import { canManageMembers, canManageTags } from "@tryghost/admin-x-framework/api/users";
import { NavMenuItem } from "./nav-menu-item";
import { useMemberCount } from "./hooks/use-member-count";
Expand Down Expand Up @@ -69,6 +70,7 @@ function MembersNavItemContent({

function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
const { data: currentUser } = useCurrentUser();
const {data: settingsData} = useBrowseSettings();
const [savedPostsExpanded, setPostsExpanded] = useNavigationExpanded('posts');
const [savedMembersExpanded, setMembersExpanded] = useNavigationExpanded('members');
const postCustomViews = useCustomSidebarViews('posts');
Expand All @@ -82,6 +84,8 @@ function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {

const showTags = currentUser && canManageTags(currentUser);
const showMembers = currentUser && canManageMembers(currentUser);
const commentsEnabled = getSettingValue<string>(settingsData?.settings, 'comments_enabled');
const showComments = !!showMembers && commentModerationEnabled && commentsEnabled !== 'off';
const isDraftPostsRouteActive = routing.isRouteActive('posts', {type: 'draft'});
const isScheduledPostsRouteActive = routing.isRouteActive('posts', {type: 'scheduled'});
const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'});
Expand Down Expand Up @@ -202,7 +206,7 @@ function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
</>
)}

{showMembers && commentModerationEnabled && (
{showComments && (
<NavMenuItem>
<NavMenuItem.Link
to="comments"
Expand Down
2 changes: 1 addition & 1 deletion apps/comments-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/comments-ui",
"version": "1.4.10",
"version": "1.4.11",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
Expand Down
20 changes: 20 additions & 0 deletions apps/comments-ui/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,24 @@ async function showComment({state, api, data: comment}: {state: EditableAppConte
};
}

async function pinComment({state, data: comment, dispatchAction}: {state: EditableAppContext, data: {id: string}, dispatchAction: DispatchActionType}) {
if (state.adminApi) {
await state.adminApi.pinComment({id: comment.id});
dispatchAction('setOrder', {order: state.order});
}

return null;
}

async function unpinComment({state, data: comment, dispatchAction}: {state: EditableAppContext, data: {id: string}, dispatchAction: DispatchActionType}) {
if (state.adminApi) {
await state.adminApi.unpinComment({id: comment.id});
dispatchAction('setOrder', {order: state.order});
}

return null;
}

async function updateCommentLikeState({state, data: comment}: {state: EditableAppContext, data: {id: string, liked: boolean}}) {
return {
comments: state.comments.map((c) => {
Expand Down Expand Up @@ -501,6 +519,8 @@ export const Actions = {
addComment,
editComment,
hideComment,
pinComment,
unpinComment,
deleteComment,
showComment,
likeComment,
Expand Down
1 change: 1 addition & 0 deletions apps/comments-ui/src/app-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type Comment = {
in_reply_to_snippet: string,
replies: Comment[],
status: string,
pinned: boolean,
liked: boolean,
count: {
replies: number,
Expand Down
62 changes: 58 additions & 4 deletions apps/comments-ui/src/components/content/comment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import ReplyButton from './buttons/reply-button';
import ReplyForm from './forms/reply-form';
import {Avatar, BlankAvatar} from './avatar';
import {Comment, OpenCommentForm, useAppContext} from '../../app-context';
import {ReactComponent as PinIcon} from '../../images/icons/pin.svg';
import {ReactComponent as PinOffIcon} from '../../images/icons/pin-off.svg';
import {Transition} from '@headlessui/react';
import {buildCommentPermalink, findCommentById, formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
import {useRelativeTime} from '../../utils/hooks';
Expand Down Expand Up @@ -128,7 +130,7 @@ const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, ope
const avatar = (<Avatar member={comment.member} />);

return (
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies} memberUuid={comment.member?.uuid}>
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies} isPinned={comment.pinned} memberUuid={comment.member?.uuid}>
<div>
{isInEditMode ? (
<>
Expand Down Expand Up @@ -183,9 +185,10 @@ const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEdi
const showMoreButton = isAdmin && comment.status === 'hidden';

return (
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
<CommentLayout avatar={avatar} hasReplies={hasReplies} isPinned={comment.pinned}>
<div className="mt-[-3px] flex items-start">
<div className="flex h-10 flex-row items-center gap-4 pb-[8px] pr-4">
<PinnedLabel comment={comment} />
<p className="text-md mt-[4px] font-sans leading-normal text-neutral-900/40 sm:text-lg dark:text-white/60">
{notPublishedMessage}
</p>
Expand Down Expand Up @@ -228,6 +231,44 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
</span>
);
};

const PinnedLabel: React.FC<{comment: Comment}> = ({comment}) => {
const {dispatchAction, isAdmin, t} = useAppContext();

if (!comment.pinned) {
return null;
}

const labelClassName = 'inline-flex items-center gap-1 rounded-full border border-amber-300/70 bg-amber-50 px-2 py-0.5 font-sans text-xs font-medium leading-none text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100';

if (isAdmin) {
const handleUnpinClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
dispatchAction('unpinComment', comment);
};

return (
<button aria-label={t('Unpin comment')} className={`${labelClassName} group hover:border-amber-400 hover:bg-amber-100 dark:hover:border-amber-400/50 dark:hover:bg-amber-400/20`} data-testid="pinned-comment-label" type="button" onClick={handleUnpinClick}>
<span className="grid size-3 shrink-0">
<PinIcon aria-hidden="true" className="col-start-1 row-start-1 size-3 group-hover:opacity-0 group-focus-visible:opacity-0" />
<PinOffIcon aria-hidden="true" className="col-start-1 row-start-1 size-3 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" />
</span>
<span className="grid justify-items-start text-left">
<span className="col-start-1 row-start-1 group-hover:opacity-0 group-focus-visible:opacity-0">{t('Pinned')}</span>
<span className="col-start-1 row-start-1 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100">{t('Unpin')}</span>
</span>
</button>
);
}

return (
<span className={labelClassName} data-testid="pinned-comment-label">
<PinIcon aria-hidden="true" className="size-3" />
{t('Pinned')}
</span>
);
};

const RepliesContainer: React.FC<RepliesProps & {className?: string}> = ({comment, className = ''}) => {
const hasReplies = comment.replies && comment.replies.length > 0;

Expand Down Expand Up @@ -322,6 +363,11 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
<span>
<MemberExpertise comment={comment}/>
{timestampElement}
{comment.pinned && (
<span className="ml-2 inline-flex align-middle">
<PinnedLabel comment={comment} />
</span>
)}
<EditedInfo comment={comment} />
</span>
</div>
Expand Down Expand Up @@ -433,10 +479,18 @@ type CommentLayoutProps = {
hasReplies: boolean;
className?: string;
memberUuid?: string;
isPinned?: boolean;
}
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = '', memberUuid = ''}) => {

const COMMENT_GAP_CLASS_NAME = 'mb-7';
const PINNED_COMMENT_GAP_CLASS_NAME = 'mb-4';
const PINNED_COMMENT_BOX_CLASS_NAME = 'bg-amber-50/70 px-3 py-3 dark:bg-amber-400/10';

const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = '', memberUuid = '', isPinned = false}) => {
const bottomMarginClassName = isPinned ? PINNED_COMMENT_GAP_CLASS_NAME : hasReplies ? 'mb-0' : COMMENT_GAP_CLASS_NAME;

return (
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-7'}`} data-member-uuid={memberUuid} data-testid="comment-component">
<div className={`flex w-full flex-row rounded-lg ${isPinned ? PINNED_COMMENT_BOX_CLASS_NAME : ''} ${bottomMarginClassName}`} data-member-uuid={memberUuid} data-pinned={isPinned ? 'true' : undefined} data-testid="comment-component">
<div className="mr-2 flex flex-col items-center justify-start sm:mr-3">
<div className={`flex-0 mb-3 sm:mb-4 ${className}`}>
{avatar}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,50 +1,101 @@
import {Comment, useAppContext, useLabs} from '../../../app-context';
import {ReactComponent as ExternalLinkIcon} from '../../../images/icons/external-link.svg';
import {ReactComponent as EyeIcon} from '../../../images/icons/eye.svg';
import {ReactComponent as EyeOffIcon} from '../../../images/icons/eye-off.svg';
import {ReactComponent as PencilIcon} from '../../../images/icons/pencil.svg';
import {ReactComponent as PinIcon} from '../../../images/icons/pin.svg';
import {ReactComponent as PinOffIcon} from '../../../images/icons/pin-off.svg';
import {ReactComponent as TrashIcon} from '../../../images/icons/trash.svg';

type Props = {
comment: Comment;
close: () => void;
showAuthorActions?: boolean;
toggleEdit?: () => void;
};
const AdminContextMenu: React.FC<Props> = ({comment, close}) => {
const AdminContextMenu: React.FC<Props> = ({comment, close, showAuthorActions = false, toggleEdit}) => {
const {dispatchAction, t, adminUrl} = useAppContext();
const labs = useLabs();

const hideComment = () => {
dispatchAction('hideComment', comment);
const closeAfter = (action: () => void) => () => {
action();
close();
};

const showComment = () => {
dispatchAction('showComment', comment);
close();
};
const editComment = toggleEdit && closeAfter(toggleEdit);
const deleteComment = closeAfter(() => {
dispatchAction('openPopup', {
type: 'deletePopup',
comment
});
});
const hideComment = closeAfter(() => dispatchAction('hideComment', comment));
const showComment = closeAfter(() => dispatchAction('showComment', comment));
const pinComment = closeAfter(() => dispatchAction('pinComment', comment));
const unpinComment = closeAfter(() => dispatchAction('unpinComment', comment));

const isHidden = comment.status !== 'published';
const canPin = !comment.parent_id && comment.status !== 'deleted';
const adminCommentUrl = adminUrl ? `${adminUrl}#/comments/?id=is:${comment.id}` : null;
const baseItemClassName = 'flex w-full items-center gap-3 rounded px-3 py-2 text-left text-[14px] leading-5 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700';
const itemClassName = `${baseItemClassName} text-neutral-900 dark:text-white`;
const destructiveItemClassName = `${baseItemClassName} text-red-600 dark:text-red-500`;
const iconClassName = 'size-4 shrink-0';

return (
<div className="flex w-full flex-col gap-0.5">
{
isHidden ?
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700" data-testid="show-button" type="button" onClick={showComment}>
<span className="hidden sm:inline">{t('Show comment')}</span><span className="sm:hidden">{t('Show')}</span>
</button>
:
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] text-red-600 transition-colors hover:bg-neutral-100 dark:text-red-500 dark:hover:bg-neutral-700" data-testid="hide-button" type="button" onClick={hideComment}>
<span className="hidden sm:inline">{t('Hide comment')}</span><span className="sm:hidden">{t('Hide')}</span>
</button>
}
{labs?.commentModeration && adminCommentUrl && (
<a
className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
className={itemClassName}
data-testid="view-in-admin-button"
href={adminCommentUrl}
rel="noopener noreferrer"
target="_blank"
onClick={close}
>
{t('View in admin')}
<ExternalLinkIcon aria-hidden="true" className={iconClassName} />
<span>{t('View in admin')}</span>
</a>
)}
{canPin && (
comment.pinned ?
<button className={itemClassName} data-testid="unpin-button" type="button" onClick={unpinComment}>
<PinOffIcon aria-hidden="true" className={iconClassName} />
<span>{t('Unpin comment')}</span>
</button>
:
<button className={itemClassName} data-testid="pin-button" type="button" onClick={pinComment}>
<PinIcon aria-hidden="true" className={iconClassName} />
<span>{t('Pin comment')}</span>
</button>
)}
{
isHidden ?
<button className={itemClassName} data-testid="show-button" type="button" onClick={showComment}>
<EyeIcon aria-hidden="true" className={iconClassName} />
<span>{t('Show comment')}</span>
</button>
:
<button className={itemClassName} data-testid="hide-button" type="button" onClick={hideComment}>
<EyeOffIcon aria-hidden="true" className={iconClassName} />
<span>{t('Hide comment')}</span>
</button>
}
{showAuthorActions && (
<div className="my-1 border-t border-neutral-200 dark:border-neutral-700" />
)}
{showAuthorActions && editComment && (
<button className={itemClassName} data-testid="edit" type="button" onClick={editComment}>
<PencilIcon aria-hidden="true" className={iconClassName} />
<span>{t('Edit')}</span>
</button>
)}
{showAuthorActions && (
<button className={destructiveItemClassName} data-testid="delete" type="button" onClick={deleteComment}>
<TrashIcon aria-hidden="true" className={iconClassName} />
<span>{t('Delete')}</span>
</button>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import {Comment, useAppContext} from '../../../app-context';
import {ReactComponent as PencilIcon} from '../../../images/icons/pencil.svg';
import {ReactComponent as TrashIcon} from '../../../images/icons/trash.svg';

type Props = {
comment: Comment;
Expand All @@ -17,13 +19,20 @@ const AuthorContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
close();
};

const baseItemClassName = 'flex w-full items-center gap-3 rounded px-3 py-2 text-left text-[14px] leading-5 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700';
const itemClassName = `${baseItemClassName} text-neutral-900 dark:text-white`;
const destructiveItemClassName = `${baseItemClassName} text-red-600 dark:text-red-500`;
const iconClassName = 'size-4 shrink-0';

return (
<div className="flex w-full flex-col gap-0.5">
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700" data-testid="edit" type="button" onClick={toggleEdit}>
{t('Edit')}
<button className={itemClassName} data-testid="edit" type="button" onClick={toggleEdit}>
<PencilIcon aria-hidden="true" className={iconClassName} />
<span>{t('Edit')}</span>
</button>
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] text-red-600 transition-colors hover:bg-neutral-100 dark:text-red-500 dark:hover:bg-neutral-700" data-testid="delete" type="button" onClick={deleteComment}>
{t('Delete')}
<button className={destructiveItemClassName} data-testid="delete" type="button" onClick={deleteComment}>
<TrashIcon aria-hidden="true" className={iconClassName} />
<span>{t('Delete')}</span>
</button>
</div>
);
Expand Down
Loading
Loading