@@ -0,0 +1,203 @@
|
||||
import {User} from '@common/auth/user';
|
||||
import {Comment} from '@common/comments/comment';
|
||||
import React, {Fragment, useContext, useState} from 'react';
|
||||
import {Checkbox} from '@common/ui/forms/toggle/checkbox';
|
||||
import {UserAvatar} from '@common/ui/images/user-avatar';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {DeleteCommentsButton} from '@common/comments/comments-datatable-page/delete-comments-button';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useUpdateComment} from '@common/comments/requests/use-update-comment';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {LinkStyle} from '@common/ui/buttons/external-link';
|
||||
import clsx from 'clsx';
|
||||
import {RestoreCommentsButton} from '@common/comments/comments-datatable-page/restore-comments-button';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
|
||||
interface Props {
|
||||
comment: Comment;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
export function CommentDatatableItem({comment, isSelected, onToggle}: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
return (
|
||||
<div className={clsx('p-14 border-b', comment.deleted && 'bg-danger/6')}>
|
||||
{comment.commentable && (
|
||||
<CommentableHeader
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
commentable={comment.commentable}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start gap-10 pt-14 md:pl-20">
|
||||
<UserAvatar className="flex-shrink-0" user={comment.user} size="md" />
|
||||
<div className="flex-auto">
|
||||
<CommentHeader comment={comment} />
|
||||
{isEditing ? (
|
||||
<EditCommentForm
|
||||
comment={comment}
|
||||
onClose={isSaved => {
|
||||
setIsEditing(false);
|
||||
if (isSaved) {
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className="text-sm my-14">{comment.content}</div>
|
||||
<div className="flex items-center gap-24 justify-between">
|
||||
<div>
|
||||
{comment.deleted ? (
|
||||
<RestoreCommentsButton commentIds={[comment.id]} />
|
||||
) : (
|
||||
<DeleteCommentsButton commentIds={[comment.id]} />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<Trans message="Edit" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-danger">
|
||||
<Trans
|
||||
message="Reported [one 1 time|other :count times]"
|
||||
values={{count: comment.reports_count}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentableHeaderProps {
|
||||
isSelected: boolean;
|
||||
onToggle: Props['onToggle'];
|
||||
commentable: NormalizedModel;
|
||||
}
|
||||
function CommentableHeader({
|
||||
isSelected,
|
||||
onToggle,
|
||||
commentable,
|
||||
}: CommentableHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="mr-14">
|
||||
<Checkbox checked={isSelected} onChange={() => onToggle()} />
|
||||
</div>
|
||||
{commentable.image && (
|
||||
<img
|
||||
className="w-20 h-20 rounded overflow-hidden object-cover mr-6"
|
||||
src={commentable.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm mr-4">{commentable.name}</div>
|
||||
<div className="text-muted text-xs">({commentable.model_type})</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentHeaderProps {
|
||||
comment: Comment;
|
||||
}
|
||||
function CommentHeader({comment}: CommentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div>
|
||||
{comment.user && (
|
||||
<UserDisplayName user={comment.user} show="display_name" />
|
||||
)}
|
||||
</div>
|
||||
<div>•</div>
|
||||
<time>
|
||||
<FormattedRelativeTime date={comment.created_at} />
|
||||
</time>
|
||||
{comment.user && (
|
||||
<div className="ml-auto hidden md:block">
|
||||
{<UserDisplayName user={comment.user} show="email" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditCommentFormProps {
|
||||
comment: Comment;
|
||||
onClose: (saved: boolean) => void;
|
||||
}
|
||||
function EditCommentForm({comment, onClose}: EditCommentFormProps) {
|
||||
const [content, setContent] = useState(comment.content);
|
||||
const updateComment = useUpdateComment();
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
updateComment.mutate(
|
||||
{commentId: comment.id, content},
|
||||
{onSuccess: () => onClose(true)},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
inputElementType="textarea"
|
||||
className="my-14"
|
||||
rows={2}
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
type="submit"
|
||||
className="mr-6"
|
||||
disabled={updateComment.isPending}
|
||||
>
|
||||
<Trans message="Save edit" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="mr-6"
|
||||
onClick={e => onClose(false)}
|
||||
disabled={updateComment.isPending}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserDisplayNameProps {
|
||||
user: User;
|
||||
show: 'display_name' | 'email';
|
||||
}
|
||||
function UserDisplayName({user, show}: UserDisplayNameProps) {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
if (auth.getUserProfileLink) {
|
||||
return (
|
||||
<Link
|
||||
to={auth.getUserProfileLink(user)}
|
||||
className={LinkStyle}
|
||||
target="_blank"
|
||||
>
|
||||
{user[show]}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className="text-muted">{user[show]}</div>;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {USER_MODEL} from '@common/auth/user';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
|
||||
export const CommentsDatatableFilters: BackendFilter[] = [
|
||||
{
|
||||
key: 'deleted',
|
||||
label: message('Status'),
|
||||
description: message('Whether comment is active or deleted'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.Select,
|
||||
defaultValue: '01',
|
||||
options: [
|
||||
{
|
||||
key: '01',
|
||||
label: message('Active'),
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
key: '02',
|
||||
label: message('Deleted'),
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'reports',
|
||||
label: message('Reported'),
|
||||
description: message('Show only reported comments'),
|
||||
defaultOperator: FilterOperator.has,
|
||||
control: {
|
||||
type: FilterControlType.BooleanToggle,
|
||||
defaultValue: '*',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'user_id',
|
||||
label: message('User'),
|
||||
description: message('User comment was created by'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: USER_MODEL,
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date comment was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date comment was last updated'),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import clsx from 'clsx';
|
||||
import {StaticPageTitle} from '@common/seo/static-page-title';
|
||||
import {DataTableHeader} from '@common/datatable/data-table-header';
|
||||
import {useBackendFilterUrlParams} from '@common/datatable/filters/backend-filter-url-params';
|
||||
import {
|
||||
GetDatatableDataParams,
|
||||
useDatatableData,
|
||||
} from '@common/datatable/requests/paginated-resources';
|
||||
import {Comment} from '@common/comments/comment';
|
||||
import {FilterList} from '@common/datatable/filters/filter-list/filter-list';
|
||||
import {SelectedStateDatatableHeader} from '@common/datatable/selected-state-datatable-header';
|
||||
import {AnimatePresence} from 'framer-motion';
|
||||
import {DeleteCommentsButton} from '@common/comments/comments-datatable-page/delete-comments-button';
|
||||
import {CommentDatatableItem} from '@common/comments/comments-datatable-page/comment-datatable-item';
|
||||
import {DataTablePaginationFooter} from '@common/datatable/data-table-pagination-footer';
|
||||
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
|
||||
import publicDiscussionsImage from './public-discussion.svg';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
import {Commentable} from '@common/comments/commentable';
|
||||
import {CommentsDatatableFilters} from '@common/comments/comments-datatable-page/comments-datatable-filters';
|
||||
|
||||
interface Props {
|
||||
hideTitle?: boolean;
|
||||
commentable?: Commentable;
|
||||
}
|
||||
export function CommentsDatatablePage({hideTitle, commentable}: Props) {
|
||||
const filters = useMemo(() => {
|
||||
return CommentsDatatableFilters.filter(
|
||||
f => f.key !== 'commentable_id' || !commentable,
|
||||
);
|
||||
}, [commentable]);
|
||||
const {encodedFilters} = useBackendFilterUrlParams(filters);
|
||||
const [params, setParams] = useState<GetDatatableDataParams>({perPage: 15});
|
||||
const [selectedComments, setSelectedComments] = useState<number[]>([]);
|
||||
const query = useDatatableData<Comment>(
|
||||
'comment',
|
||||
{
|
||||
...params,
|
||||
with: 'commentable',
|
||||
withCount: 'reports',
|
||||
filters: encodedFilters,
|
||||
commentable_type: commentable?.model_type,
|
||||
commentable_id: commentable?.id,
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
setSelectedComments([]);
|
||||
},
|
||||
);
|
||||
|
||||
const toggleComment = useCallback(
|
||||
(id: number) => {
|
||||
const newValues = [...selectedComments];
|
||||
if (!newValues.includes(id)) {
|
||||
newValues.push(id);
|
||||
} else {
|
||||
const index = newValues.indexOf(id);
|
||||
newValues.splice(index, 1);
|
||||
}
|
||||
setSelectedComments(newValues);
|
||||
},
|
||||
[selectedComments, setSelectedComments],
|
||||
);
|
||||
|
||||
const isFiltering = !!(params.query || params.filters || encodedFilters);
|
||||
const pagination = query.data?.pagination;
|
||||
|
||||
return (
|
||||
<div className={clsx(!hideTitle && 'p-12 md:p-24')}>
|
||||
<div className={clsx('mb-16')}>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Comments" />
|
||||
</StaticPageTitle>
|
||||
{!hideTitle && (
|
||||
<h1 className="text-3xl font-light">
|
||||
<Trans message="Comments" />
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{selectedComments.length ? (
|
||||
<SelectedStateDatatableHeader
|
||||
selectedItemsCount={selectedComments.length}
|
||||
actions={
|
||||
<DeleteCommentsButton
|
||||
size="sm"
|
||||
variant="flat"
|
||||
commentIds={selectedComments}
|
||||
/>
|
||||
}
|
||||
key="selected"
|
||||
/>
|
||||
) : (
|
||||
<DataTableHeader
|
||||
filters={filters}
|
||||
searchValue={params.query}
|
||||
onSearchChange={query => setParams({...params, query})}
|
||||
key="default"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<FilterList className="mb-14" filters={filters} />
|
||||
|
||||
{query.isLoading ? (
|
||||
<FullPageLoader className="min-h-200" />
|
||||
) : (
|
||||
<div className="rounded border-x border-t">
|
||||
{pagination?.data.map(comment => (
|
||||
<CommentDatatableItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
isSelected={selectedComments.includes(comment.id)}
|
||||
onToggle={() => toggleComment(comment.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(query.isFetched || query.isPlaceholderData) &&
|
||||
!pagination?.data.length ? (
|
||||
<DataTableEmptyStateMessage
|
||||
className="pt-50"
|
||||
isFiltering={isFiltering}
|
||||
image={publicDiscussionsImage}
|
||||
title={<Trans message="No comments have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching comments" />}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
<DataTablePaginationFooter
|
||||
className="mt-10"
|
||||
query={query}
|
||||
onPageChange={page => setParams({...params, page})}
|
||||
onPerPageChange={perPage => setParams({...params, perPage})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useDeleteComments} from '@common/comments/requests/use-delete-comments';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import React from 'react';
|
||||
import {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';
|
||||
import {ButtonSize} from '@common/ui/buttons/button-size';
|
||||
|
||||
interface DeleteCommentsButtonProps {
|
||||
commentIds: number[];
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
export function DeleteCommentsButton({
|
||||
commentIds,
|
||||
variant = 'outline',
|
||||
size = 'xs',
|
||||
}: DeleteCommentsButtonProps) {
|
||||
const deleteComments = useDeleteComments();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
deleteComments.mutate(
|
||||
{commentIds},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
color="danger"
|
||||
className="mr-10"
|
||||
disabled={deleteComments.isPending}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={
|
||||
<Trans
|
||||
message="Delete [one comment|other :count comments]"
|
||||
values={{count: commentIds.length}}
|
||||
/>
|
||||
}
|
||||
body={
|
||||
commentIds.length > 1 ? (
|
||||
<Trans message="Are you sure you want to delete selected comments?" />
|
||||
) : (
|
||||
<Trans message="Are you sure you want to delete this comment?" />
|
||||
)
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,41 @@
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';
|
||||
import {ButtonSize} from '@common/ui/buttons/button-size';
|
||||
import {useRestoreComments} from '@common/comments/requests/use-restore-comments';
|
||||
|
||||
interface Props {
|
||||
commentIds: number[];
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
export function RestoreCommentsButton({
|
||||
commentIds,
|
||||
variant = 'outline',
|
||||
size = 'xs',
|
||||
}: Props) {
|
||||
const restoreComments = useRestoreComments();
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className="mr-10"
|
||||
disabled={restoreComments.isPending}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
restoreComments.mutate(
|
||||
{commentIds},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans message="Restore" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user