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