315
resources/client/reviews/review-list/review-list-item.tsx
Executable file
315
resources/client/reviews/review-list/review-list-item.tsx
Executable file
@@ -0,0 +1,315 @@
|
||||
import {Review} from '@app/titles/models/review';
|
||||
import {UserAvatar} from '@common/ui/images/user-avatar';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React, {
|
||||
Fragment,
|
||||
ReactElement,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useSubmitReviewFeedback} from '@app/reviews/requests/use-submit-review-feedback';
|
||||
import clsx from 'clsx';
|
||||
import {useAuth} from '@common/auth/use-auth';
|
||||
import {useAuthClickCapture} from '@app/use-auth-click-capture';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Tooltip} from '@common/ui/tooltip/tooltip';
|
||||
import {useSubmitReport} from '@common/reports/requests/use-submit-report';
|
||||
import {useDeleteReport} from '@common/reports/requests/use-delete-report';
|
||||
import {ShareIcon} from '@common/icons/material/Share';
|
||||
import useClipboard from 'react-use-clipboard';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
} from '@common/ui/navigation/menu/menu-trigger';
|
||||
import {MoreVertIcon} from '@common/icons/material/MoreVert';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteReviews} from '@app/reviews/requests/use-delete-reviews';
|
||||
import {User} from '@common/auth/user';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
|
||||
interface Props {
|
||||
review: Review;
|
||||
isShared?: boolean;
|
||||
hideShareButton?: boolean;
|
||||
avatar?: ReactElement;
|
||||
}
|
||||
export function ReviewListItem({
|
||||
review,
|
||||
isShared,
|
||||
hideShareButton,
|
||||
avatar,
|
||||
}: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrolled = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isShared && !scrolled.current) {
|
||||
setTimeout(() => {
|
||||
ref.current?.scrollIntoView({behavior: 'smooth'});
|
||||
scrolled.current = true;
|
||||
}, 50);
|
||||
}
|
||||
}, [isShared]);
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{isShared && (
|
||||
<div className="mb-8 mt-16 text-sm">
|
||||
<Trans message="Shared review" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'group flex min-h-70 items-start gap-24 rounded py-18',
|
||||
isShared && 'mb-34 border bg-alt pl-12',
|
||||
)}
|
||||
>
|
||||
{!isMobile &&
|
||||
(avatar || <UserAvatar user={review.user} size="xl" circle />)}
|
||||
<div className="flex-auto text-sm">
|
||||
<div className="mb-4 flex items-center gap-8">
|
||||
{review.user && <UserDisplayName user={review.user} />}
|
||||
<time className="text-xs text-muted">
|
||||
<FormattedRelativeTime date={review.created_at} />
|
||||
</time>
|
||||
</div>
|
||||
<TitleRating className="mb-8 mt-10" score={review.score} />
|
||||
{review.title && (
|
||||
<div className="mb-8 text-base font-medium">{review.title}</div>
|
||||
)}
|
||||
<div className="whitespace-break-spaces text-sm">{review.body}</div>
|
||||
<div className="mt-16 items-center gap-8 md:flex">
|
||||
<Feedback review={review} />
|
||||
{!hideShareButton && <ShareButton review={review} />}
|
||||
<ReviewOptionsTrigger review={review} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ShareButtonProps {
|
||||
review: Review;
|
||||
}
|
||||
function ShareButton({review}: ShareButtonProps) {
|
||||
const {base_url} = useSettings();
|
||||
const location = useLocation();
|
||||
const url = `${base_url}${location.pathname}?reviewId=${review.id}`;
|
||||
const [, copyLink] = useClipboard(url);
|
||||
return (
|
||||
<Tooltip label={<Trans message="Share" />}>
|
||||
<IconButton
|
||||
className="text-muted"
|
||||
onClick={() => {
|
||||
copyLink();
|
||||
toast(message('Review link copied to clipboard'));
|
||||
}}
|
||||
>
|
||||
<ShareIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeedbackProps {
|
||||
review: Review;
|
||||
}
|
||||
function Feedback({review}: FeedbackProps) {
|
||||
const {user} = useAuth();
|
||||
const authHandler = useAuthClickCapture();
|
||||
const submitFeedback = useSubmitReviewFeedback(review);
|
||||
const isDisabled =
|
||||
submitFeedback.isPending || (user != null && user.id === review.user_id);
|
||||
|
||||
const [helpfulCount, setHelpfulCount] = useState(review.helpful_count || 1);
|
||||
const [total, setTotal] = useState(
|
||||
review.helpful_count + review.not_helpful_count || 1,
|
||||
);
|
||||
|
||||
let initialFeedback: string | undefined;
|
||||
if (review.current_user_feedback != null) {
|
||||
initialFeedback = review.current_user_feedback ? 'helpful' : 'not_helpful';
|
||||
}
|
||||
const [currentFeedback, setCurrentFeedback] = useState<string | undefined>(
|
||||
initialFeedback,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mr-auto flex flex-wrap items-center gap-6 max-md:mb-12">
|
||||
<div className="text-xs text-muted">
|
||||
<Trans
|
||||
message=":helpfulCount out of :total people found this helpful. Was this review helpful?"
|
||||
values={{helpfulCount, total}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 pb-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className={clsx(
|
||||
'uppercase',
|
||||
currentFeedback === 'helpful' && 'pointer-events-none',
|
||||
)}
|
||||
color={currentFeedback === 'helpful' ? 'primary' : undefined}
|
||||
disabled={isDisabled}
|
||||
onClickCapture={authHandler}
|
||||
onClick={() =>
|
||||
submitFeedback.mutate(
|
||||
{isHelpful: true},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHelpfulCount(count => count + 1);
|
||||
setCurrentFeedback('helpful');
|
||||
if (!currentFeedback) {
|
||||
setTotal(count => count + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans message="Yes" />
|
||||
</Button>
|
||||
<div className="h-14 w-1 bg-divider"></div>
|
||||
<Button
|
||||
variant="link"
|
||||
className={clsx(
|
||||
'uppercase',
|
||||
currentFeedback === 'not_helpful' && 'pointer-events-none',
|
||||
)}
|
||||
color={currentFeedback === 'not_helpful' ? 'primary' : undefined}
|
||||
disabled={isDisabled}
|
||||
onClickCapture={authHandler}
|
||||
onClick={() =>
|
||||
submitFeedback.mutate(
|
||||
{isHelpful: false},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setHelpfulCount(count => count - 1);
|
||||
setCurrentFeedback('not_helpful');
|
||||
if (!currentFeedback) {
|
||||
setTotal(count => count + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans message="No" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewOptionsTriggerProps {
|
||||
review: Review;
|
||||
}
|
||||
export function ReviewOptionsTrigger({review}: ReviewOptionsTriggerProps) {
|
||||
const {user, hasPermission} = useAuth();
|
||||
const report = useSubmitReport(review);
|
||||
const deleteReport = useDeleteReport(review);
|
||||
const [isReported, setIsReported] = useState(review.current_user_reported);
|
||||
const handleReport = () => {
|
||||
if (isReported) {
|
||||
deleteReport.mutate(undefined, {
|
||||
onSuccess: () => setIsReported(false),
|
||||
});
|
||||
} else {
|
||||
report.mutate({}, {onSuccess: () => setIsReported(true)});
|
||||
}
|
||||
};
|
||||
|
||||
const deleteReview = useDeleteReviews();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const showDeleteButton =
|
||||
(user && review.user_id === user.id) || hasPermission('reviews.delete');
|
||||
const handleDelete = (isConfirmed: boolean) => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
if (isConfirmed) {
|
||||
deleteReview.mutate({reviewIds: [review.id]});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<MenuTrigger>
|
||||
<IconButton className="text-muted" aria-label="More options">
|
||||
<MoreVertIcon />
|
||||
</IconButton>
|
||||
<Menu>
|
||||
<MenuItem value="report" onSelected={() => handleReport()}>
|
||||
{isReported ? (
|
||||
<Trans message="Remove report" />
|
||||
) : (
|
||||
<Trans message="Report review" />
|
||||
)}
|
||||
</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 review?" />}
|
||||
body={
|
||||
<Trans message="Are you sure you want to delete this review?" />
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserDisplayNameProps {
|
||||
user: User;
|
||||
}
|
||||
function UserDisplayName({user}: UserDisplayNameProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
const sharedClassName = 'flex items-center gap-8 text-base font-medium';
|
||||
if (auth.getUserProfileLink) {
|
||||
return (
|
||||
<Fragment>
|
||||
{isMobile && <UserAvatar user={user} size="sm" circle />}
|
||||
<Link
|
||||
to={auth.getUserProfileLink(user)}
|
||||
className={clsx('hover:underline', sharedClassName)}
|
||||
>
|
||||
{user.display_name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={sharedClassName}>
|
||||
{isMobile && <UserAvatar user={user} size="sm" circle />}
|
||||
{user.display_name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user