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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user