37
common/resources/client/comments/comment-list/account-required-card.tsx
Executable file
37
common/resources/client/comments/comment-list/account-required-card.tsx
Executable file
@@ -0,0 +1,37 @@
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {LinkStyle} from '@common/ui/buttons/external-link';
|
||||
import {MessageDescriptor} from '@common/i18n/message-descriptor';
|
||||
|
||||
interface Props {
|
||||
message: MessageDescriptor;
|
||||
}
|
||||
export function AccountRequiredCard({message}: Props) {
|
||||
const {user} = useAuth();
|
||||
if (user) return null;
|
||||
return (
|
||||
<div className="border border-dashed py-30 px-20 my-40 mx-auto text-center max-w-850 rounded">
|
||||
<div className="text-xl font-semibold mb-8">
|
||||
<Trans message="Account required" />
|
||||
</div>
|
||||
<div className="text-muted text-base">
|
||||
<Trans
|
||||
{...message}
|
||||
values={{
|
||||
l: parts => (
|
||||
<Link className={LinkStyle} to="/login">
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
r: parts => (
|
||||
<Link className={LinkStyle} to="/register">
|
||||
{parts}
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
common/resources/client/comments/comment-list/comment-list-item.tsx
Executable file
224
common/resources/client/comments/comment-list/comment-list-item.tsx
Executable file
@@ -0,0 +1,224 @@
|
||||
import React, {Fragment, memo, useContext, useState} from 'react';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Comment} from '@common/comments/comment';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {UserAvatar} from '@common/ui/images/user-avatar';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {NewCommentForm} from '@common/comments/new-comment-form';
|
||||
import {User} from '@common/auth/user';
|
||||
import {Commentable} from '@common/comments/commentable';
|
||||
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 {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {FormattedDuration} from '@common/i18n/formatted-duration';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {ThumbButtons} from '@common/votes/thumb-buttons';
|
||||
import {ReplyIcon} from '@common/icons/material/Reply';
|
||||
import {MoreVertIcon} from '@common/icons/material/MoreVert';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {useSubmitReport} from '@common/reports/requests/use-submit-report';
|
||||
|
||||
interface CommentListItemProps {
|
||||
comment: Comment;
|
||||
commentable: Commentable;
|
||||
canDelete?: boolean;
|
||||
}
|
||||
export function CommentListItem({
|
||||
comment,
|
||||
commentable,
|
||||
// user can delete comment if they have created it, or they have relevant permissions on commentable
|
||||
canDelete,
|
||||
}: CommentListItemProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const {user, hasPermission} = useAuth();
|
||||
const [replyFormVisible, setReplyFormVisible] = useState(false);
|
||||
const showReplyButton =
|
||||
user != null &&
|
||||
!comment.deleted &&
|
||||
!isMobile &&
|
||||
comment.depth < 5 &&
|
||||
hasPermission('comments.create');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{paddingLeft: `${comment.depth * 20}px`}}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setReplyFormVisible(!replyFormVisible);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="group flex min-h-70 items-start gap-24 py-18">
|
||||
<UserAvatar user={comment.user} size={isMobile ? 'lg' : 'xl'} circle />
|
||||
<div className="flex-auto text-sm">
|
||||
<div className="mb-4 flex items-center gap-8">
|
||||
{comment.user && <UserDisplayName user={comment.user} />}
|
||||
<time className="text-xs text-muted">
|
||||
<FormattedRelativeTime date={comment.created_at} />
|
||||
</time>
|
||||
{comment.position ? (
|
||||
<Position commentable={commentable} position={comment.position} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="whitespace-pre-line">
|
||||
{comment.deleted ? (
|
||||
<span className="italic text-muted">
|
||||
<Trans message="[COMMENT DELETED]" />
|
||||
</span>
|
||||
) : (
|
||||
comment.content
|
||||
)}
|
||||
</div>
|
||||
{!comment.deleted && (
|
||||
<div className="-ml-8 mt-10 flex items-center gap-8">
|
||||
{showReplyButton && (
|
||||
<Button
|
||||
sizeClassName="text-sm px-8 py-4"
|
||||
startIcon={<ReplyIcon />}
|
||||
onClick={() => setReplyFormVisible(!replyFormVisible)}
|
||||
>
|
||||
<Trans message="Reply" />
|
||||
</Button>
|
||||
)}
|
||||
<ThumbButtons model={comment} showUpvotesOnly />
|
||||
<CommentOptionsTrigger
|
||||
comment={comment}
|
||||
canDelete={canDelete}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{replyFormVisible ? (
|
||||
<NewCommentForm
|
||||
className={!comment?.depth ? 'pl-20' : undefined}
|
||||
commentable={commentable}
|
||||
inReplyTo={comment}
|
||||
autoFocus
|
||||
onSuccess={() => {
|
||||
setReplyFormVisible(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PositionProps {
|
||||
commentable: Commentable;
|
||||
position: number;
|
||||
}
|
||||
const Position = memo(({commentable, position}: PositionProps) => {
|
||||
if (!commentable.duration) return null;
|
||||
const seconds = (position / 100) * (commentable.duration / 1000);
|
||||
return (
|
||||
<span className="text-xs text-muted">
|
||||
<Trans
|
||||
message="at :position"
|
||||
values={{
|
||||
position: <FormattedDuration seconds={seconds} />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
interface DeleteCommentsButtonProps {
|
||||
comment: Comment;
|
||||
canDelete?: boolean;
|
||||
user: User | null;
|
||||
}
|
||||
export function CommentOptionsTrigger({
|
||||
comment,
|
||||
canDelete,
|
||||
user,
|
||||
}: DeleteCommentsButtonProps) {
|
||||
const deleteComments = useDeleteComments();
|
||||
const reportComment = useSubmitReport(comment);
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const showDeleteButton =
|
||||
(comment.user_id === user?.id || canDelete) && !comment.deleted;
|
||||
|
||||
const handleReport = () => {
|
||||
reportComment.mutate({});
|
||||
};
|
||||
|
||||
const handleDelete = (isConfirmed: boolean) => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
if (isConfirmed) {
|
||||
deleteComments.mutate(
|
||||
{commentIds: [comment.id]},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<MenuTrigger>
|
||||
<Button startIcon={<MoreVertIcon />} sizeClassName="text-sm px-8 py-4">
|
||||
<Trans message="More" />
|
||||
</Button>
|
||||
<Menu>
|
||||
<MenuItem value="report" onSelected={() => handleReport()}>
|
||||
<Trans message="Report comment" />
|
||||
</MenuItem>
|
||||
{showDeleteButton && (
|
||||
<MenuItem
|
||||
value="delete"
|
||||
onSelected={() => setIsDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={isConfirmed => handleDelete(isConfirmed)}
|
||||
>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete comment?" />}
|
||||
body={
|
||||
<Trans message="Are you sure you want to delete this comment?" />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserDisplayNameProps {
|
||||
user: User;
|
||||
}
|
||||
function UserDisplayName({user}: UserDisplayNameProps) {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
if (auth.getUserProfileLink) {
|
||||
return (
|
||||
<Link
|
||||
to={auth.getUserProfileLink(user)}
|
||||
className="text-base font-medium hover:underline"
|
||||
>
|
||||
{user.display_name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className="text-base font-medium">{user.display_name}</div>;
|
||||
}
|
||||
132
common/resources/client/comments/comment-list/comment-list.tsx
Executable file
132
common/resources/client/comments/comment-list/comment-list.tsx
Executable file
@@ -0,0 +1,132 @@
|
||||
import {Comment} from '@common/comments/comment';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {CommentIcon} from '@common/icons/material/Comment';
|
||||
import {Commentable} from '@common/comments/commentable';
|
||||
import {useComments} from '@common/comments/requests/use-comments';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {CommentListItem} from '@common/comments/comment-list/comment-list-item';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {ReactNode} from 'react';
|
||||
import {AccountRequiredCard} from '@common/comments/comment-list/account-required-card';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
const accountRequiredMessage = message(
|
||||
'Please <l>login</l> or <r>create account</r> to comment'
|
||||
);
|
||||
|
||||
interface CommentListProps {
|
||||
commentable: Commentable;
|
||||
canDeleteAllComments?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
perPage?: number;
|
||||
}
|
||||
export function CommentList({
|
||||
className,
|
||||
commentable,
|
||||
canDeleteAllComments = false,
|
||||
children,
|
||||
perPage = 25,
|
||||
}: CommentListProps) {
|
||||
const {items, totalItems, ...query} = useComments(commentable, {perPage});
|
||||
|
||||
if (query.isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-8 pb-8 border-b flex items-center gap-8">
|
||||
<CommentIcon size="sm" className="text-muted" />
|
||||
{query.isInitialLoading ? (
|
||||
<Trans message="Loading comments..." />
|
||||
) : (
|
||||
<Trans
|
||||
message=":count comments"
|
||||
values={{count: <FormattedNumber value={totalItems || 0} />}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
<AccountRequiredCard message={accountRequiredMessage} />
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{query.isInitialLoading ? (
|
||||
<CommentSkeletons count={4} />
|
||||
) : (
|
||||
<CommentListItems
|
||||
comments={items}
|
||||
canDeleteAllComments={canDeleteAllComments}
|
||||
commentable={commentable}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<InfiniteScrollSentinel query={query} variant="loadMore" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentListItemsProps {
|
||||
comments: Comment[];
|
||||
canDeleteAllComments: boolean;
|
||||
commentable: Commentable;
|
||||
}
|
||||
function CommentListItems({
|
||||
comments,
|
||||
commentable,
|
||||
canDeleteAllComments,
|
||||
}: CommentListItemsProps) {
|
||||
if (!comments.length) {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-24"
|
||||
size="sm"
|
||||
title={<Trans message="Seems a little quiet over here" />}
|
||||
description={<Trans message="Be the first to comment" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div key="comments" {...opacityAnimation}>
|
||||
{comments.map(comment => (
|
||||
<CommentListItem
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
commentable={commentable}
|
||||
canDelete={canDeleteAllComments}
|
||||
/>
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentSkeletonsProps {
|
||||
count: number;
|
||||
}
|
||||
function CommentSkeletons({count}: CommentSkeletonsProps) {
|
||||
return (
|
||||
<m.div key="loading-skeleton" {...opacityAnimation}>
|
||||
{[...new Array(count).keys()].map(index => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-24 py-18 min-h-70 group"
|
||||
>
|
||||
<Skeleton variant="avatar" radius="rounded-full" size="w-60 h-60" />
|
||||
<div className="text-sm flex-auto">
|
||||
<Skeleton className="text-base max-w-184 mb-4" />
|
||||
<Skeleton className="text-sm" />
|
||||
<div className="flex items-center gap-8 mt-10">
|
||||
<Skeleton className="text-sm max-w-70" />
|
||||
<Skeleton className="text-sm max-w-40" />
|
||||
<Skeleton className="text-sm max-w-60" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
17
common/resources/client/comments/comment.ts
Executable file
17
common/resources/client/comments/comment.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
import {User} from '@common/auth/user';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {VotableModel} from '@common/votes/votable-model';
|
||||
|
||||
export interface Comment extends VotableModel {
|
||||
content: string;
|
||||
user_id: number;
|
||||
user?: User;
|
||||
depth: number;
|
||||
deleted: boolean;
|
||||
commentable_id: number;
|
||||
commentable_type: string;
|
||||
commentable?: NormalizedModel;
|
||||
children: Comment[];
|
||||
position?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
5
common/resources/client/comments/commentable.ts
Executable file
5
common/resources/client/comments/commentable.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
export interface Commentable {
|
||||
id: number;
|
||||
model_type: string;
|
||||
duration?: number;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
123
common/resources/client/comments/new-comment-form.tsx
Executable file
123
common/resources/client/comments/new-comment-form.tsx
Executable file
@@ -0,0 +1,123 @@
|
||||
import {Commentable} from '@common/comments/commentable';
|
||||
import {Comment} from '@common/comments/comment';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {useCreateComment} from '@common/comments/requests/use-create-comment';
|
||||
import {RefObject, useState} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {Avatar} from '@common/ui/images/avatar';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {useObjectRef} from '@react-aria/utils';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
|
||||
export interface NewCommentFormProps {
|
||||
commentable: Commentable;
|
||||
inReplyTo?: Comment;
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
// additional data that should be sent to backend when creating comments
|
||||
payload?: Record<string, number | string>;
|
||||
}
|
||||
export function NewCommentForm({
|
||||
commentable,
|
||||
inReplyTo,
|
||||
onSuccess,
|
||||
className,
|
||||
autoFocus,
|
||||
payload,
|
||||
...props
|
||||
}: NewCommentFormProps) {
|
||||
const {trans} = useTrans();
|
||||
const {user} = useAuth();
|
||||
const createComment = useCreateComment();
|
||||
const inputRef = useObjectRef<HTMLInputElement>(props.inputRef);
|
||||
const [inputIsExpanded, setInputIsExpanded] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const clearInput = () => {
|
||||
setInputIsExpanded(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={clsx('py-6 flex gap-24', className)}
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (inputValue && !createComment.isPending) {
|
||||
createComment.mutate(
|
||||
{
|
||||
...payload,
|
||||
commentable,
|
||||
content: inputValue,
|
||||
inReplyTo,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
clearInput();
|
||||
onSuccess?.();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar size="xl" circle src={user?.avatar} label={user?.display_name} />
|
||||
<div className="flex-auto">
|
||||
<div className="text-xs text-muted mb-10">
|
||||
<Trans
|
||||
message="Comment as :name"
|
||||
values={{
|
||||
name: (
|
||||
<span className="font-medium text">{user?.display_name}</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
inputElementType="textarea"
|
||||
inputClassName="resize-none"
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onFocus={() => setInputIsExpanded(true)}
|
||||
onBlur={() => {
|
||||
if (!inputValue) {
|
||||
setInputIsExpanded(false);
|
||||
}
|
||||
}}
|
||||
minLength={3}
|
||||
rows={inputIsExpanded ? 3 : 1}
|
||||
placeholder={
|
||||
inReplyTo
|
||||
? trans(message('Write a reply'))
|
||||
: trans(message('Leave a comment'))
|
||||
}
|
||||
/>
|
||||
{inputIsExpanded && (
|
||||
<div className="flex items-center gap-12 justify-end mt-12">
|
||||
<Button variant="outline" onClick={() => clearInput()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={createComment.isPending || inputValue.length < 3}
|
||||
>
|
||||
<Trans message="Comment" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
30
common/resources/client/comments/requests/use-comments.ts
Executable file
30
common/resources/client/comments/requests/use-comments.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {Commentable} from '@common/comments/commentable';
|
||||
import {Comment} from '@common/comments/comment';
|
||||
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
|
||||
interface QueryParams {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export function commentsQueryKey(
|
||||
commentable: Commentable,
|
||||
params: QueryParams = {}
|
||||
) {
|
||||
return ['comment', `${commentable.id}-${commentable.model_type}`, params];
|
||||
}
|
||||
|
||||
export function useComments(
|
||||
commentable: Commentable,
|
||||
params: QueryParams = {}
|
||||
) {
|
||||
return useInfiniteData<Comment>({
|
||||
queryKey: commentsQueryKey(commentable, params),
|
||||
endpoint: 'commentable/comments',
|
||||
//paginate: 'cursor',
|
||||
queryParams: {
|
||||
commentable_type: commentable.model_type,
|
||||
commentable_id: commentable.id,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
||||
51
common/resources/client/comments/requests/use-create-comment.ts
Executable file
51
common/resources/client/comments/requests/use-create-comment.ts
Executable file
@@ -0,0 +1,51 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
import {Commentable} from '@common/comments/commentable';
|
||||
import {Comment} from '@common/comments/comment';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
export interface CreateCommentPayload {
|
||||
commentable: Commentable;
|
||||
content: string;
|
||||
inReplyTo?: Comment;
|
||||
}
|
||||
|
||||
export function useCreateComment() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (props: CreateCommentPayload) => createComment(props),
|
||||
onSuccess: async (response, props) => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
'comment',
|
||||
`${props.commentable.id}-${props.commentable.model_type}`,
|
||||
],
|
||||
});
|
||||
toast(message('Comment posted'));
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function createComment({
|
||||
commentable,
|
||||
content,
|
||||
inReplyTo,
|
||||
...other
|
||||
}: CreateCommentPayload): Promise<Response> {
|
||||
const payload = {
|
||||
commentable_id: commentable.id,
|
||||
commentable_type: commentable.model_type,
|
||||
content,
|
||||
inReplyTo,
|
||||
...other,
|
||||
};
|
||||
return apiClient.post('comment', payload).then(r => r.data);
|
||||
}
|
||||
32
common/resources/client/comments/requests/use-delete-comments.ts
Executable file
32
common/resources/client/comments/requests/use-delete-comments.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient} from '@common/http/query-client';
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
commentIds: number[];
|
||||
}
|
||||
|
||||
export function useDeleteComments() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => deleteComments(payload),
|
||||
onSuccess: (response, payload) => {
|
||||
toast(
|
||||
message('[one Comment deleted|other Deleted :count comments]', {
|
||||
values: {count: payload.commentIds.length},
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function deleteComments({commentIds}: Payload): Promise<Response> {
|
||||
return apiClient.delete(`comment/${commentIds.join(',')}`).then(r => r.data);
|
||||
}
|
||||
32
common/resources/client/comments/requests/use-restore-comments.ts
Executable file
32
common/resources/client/comments/requests/use-restore-comments.ts
Executable file
@@ -0,0 +1,32 @@
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {toast} from '../../ui/toast/toast';
|
||||
import {message} from '../../i18n/message';
|
||||
import {showHttpErrorToast} from '../../utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
commentIds: number[];
|
||||
}
|
||||
|
||||
export function useRestoreComments() {
|
||||
return useMutation({
|
||||
mutationFn: (payload: Payload) => restoreComment(payload),
|
||||
onSuccess: (response, payload) => {
|
||||
toast(
|
||||
message('Restored [one 1 comment|other :count comments]', {
|
||||
values: {count: payload.commentIds.length},
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function restoreComment({commentIds}: Payload): Promise<Response> {
|
||||
return apiClient.post('comment/restore', {commentIds}).then(r => r.data);
|
||||
}
|
||||
30
common/resources/client/comments/requests/use-update-comment.ts
Executable file
30
common/resources/client/comments/requests/use-update-comment.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
import {BackendResponse} from '@common/http/backend-response/backend-response';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {apiClient, queryClient} from '@common/http/query-client';
|
||||
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
//
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
commentId: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function useUpdateComment() {
|
||||
return useMutation({
|
||||
mutationFn: (props: Payload) => updateComment(props),
|
||||
onSuccess: () => {
|
||||
toast(message('Comment updated'));
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
},
|
||||
onError: err => showHttpErrorToast(err),
|
||||
});
|
||||
}
|
||||
|
||||
function updateComment({commentId, content}: Payload): Promise<Response> {
|
||||
return apiClient.put(`comment/${commentId}`, {content}).then(r => r.data);
|
||||
}
|
||||
Reference in New Issue
Block a user