first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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;
}

View File

@@ -0,0 +1,5 @@
export interface Commentable {
id: number;
model_type: string;
duration?: number;
}

View File

@@ -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>&bull;</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>;
}

View File

@@ -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'),
}),
];

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View 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>
);
}

View 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,
},
});
}

View 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);
}

View 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);
}

View 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);
}

View 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);
}