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,178 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import {useAuth} from '@common/auth/use-auth';
import {
CreateReviewPayload,
useCreateReview,
} from '@app/reviews/requests/use-create-review';
import {useForm} from 'react-hook-form';
import {Form} from '@common/ui/forms/form';
import {Avatar} from '@common/ui/images/avatar';
import {Trans} from '@common/i18n/trans';
import {StarSelector} from '@app/reviews/review-list/star-selector';
import {Button} from '@common/ui/buttons/button';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Reviewable} from '@app/reviews/reviewable';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import clsx from 'clsx';
import {Review} from '@app/titles/models/review';
export interface NewReviewFormActions {
openReviewPanel: () => void;
}
interface Props {
reviewable: Reviewable;
currentReview?: Review;
className?: string;
disabled?: boolean;
}
export const NewReviewForm = forwardRef<NewReviewFormActions, Props>(
({reviewable, currentReview, className, disabled}, ref) => {
const [isExpanded, setIsExpanded] = useState(false);
const {user} = useAuth();
const form = useForm<CreateReviewPayload>({
defaultValues: {
score: 8,
},
});
useEffect(() => {
if (currentReview) {
form.setValue('title', currentReview.title);
form.setValue('body', currentReview.body);
form.setValue('score', currentReview.score);
}
}, [form, currentReview]);
const openReviewPanel = useCallback(() => {
setIsExpanded(true);
}, []);
useImperativeHandle(
ref,
() => ({
openReviewPanel,
}),
[openReviewPanel],
);
const createReview = useCreateReview(form);
return (
<Form
className={clsx('rounded border bg-alt p-14', className)}
form={form}
onSubmit={newValues => {
if (disabled) return;
createReview.mutate(
{
...newValues,
reviewable,
},
{
onSuccess: () => {
toast(message('Review posted'));
setIsExpanded(false);
},
},
);
}}
>
<div className="items-center gap-24 lg:flex">
<Avatar
size="xl"
circle
src={user?.avatar}
label={user?.display_name}
/>
<div className="flex-auto">
<div className="mb-4 text-xs text-muted max-md:mt-10">
<Trans
message="Review as :name"
values={{
name: (
<span className="font-medium text">
{user?.display_name}
</span>
),
}}
/>
</div>
<StarSelector
readonly={disabled}
className="-ml-8 max-lg:mb-12"
count={10}
value={disabled ? 0 : form.watch('score')}
onValueChange={newScore => {
form.setValue('score', newScore);
}}
/>
</div>
{!isExpanded && (
<Button
variant="flat"
color="primary"
onClick={() => openReviewPanel()}
disabled={!user || disabled}
>
{currentReview ? (
<Trans message="Update review" />
) : (
<Trans message="Add review" />
)}
</Button>
)}
</div>
{isExpanded && (
<div className="mt-24">
<FormTextField
name="title"
className="mb-24"
label={<Trans message="Title" />}
labelSuffix={<Trans message="10 character minimum" />}
autoFocus
minLength={10}
required
/>
<FormTextField
name="body"
label={<Trans message="Review" />}
labelSuffix={<Trans message="100 character minimum" />}
inputElementType="textarea"
rows={5}
minLength={100}
required
/>
<div className="mt-16 flex items-center justify-end gap-8">
<Button
variant="outline"
className="min-w-100"
onClick={() => {
setIsExpanded(false);
form.reset(currentReview);
}}
>
<Trans message="Cancel" />
</Button>
<Button
type="submit"
variant="flat"
color="primary"
className="min-w-100"
disabled={createReview.isPending}
>
<Trans message="Post" />
</Button>
</div>
</div>
)}
</Form>
);
},
);

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

View File

@@ -0,0 +1,66 @@
import {message} from '@common/i18n/message';
import {
Menu,
MenuItem,
MenuTrigger,
} from '@common/ui/navigation/menu/menu-trigger';
import {Button, ButtonProps} from '@common/ui/buttons/button';
import {SortIcon} from '@common/icons/material/Sort';
import {Trans} from '@common/i18n/trans';
const SortOptions = [
{
value: 'created_at:desc',
label: message('Newest'),
},
{
value: 'created_at:asc',
label: message('Oldest'),
},
{
value: 'mostHelpful',
label: message('Most helpful'),
},
{
value: 'reports_count:desc',
label: message('Most reported'),
},
];
interface Props {
value: string;
onValueChange: (newValue: string) => void;
color?: ButtonProps['color'];
showReportsItem?: boolean;
}
export function ReviewListSortButton({
value,
onValueChange,
color,
showReportsItem,
}: Props) {
let selectedOption = SortOptions.find(option => option.value === value);
if (!selectedOption) {
selectedOption = SortOptions[0];
}
return (
<MenuTrigger
selectedValue={value}
onSelectionChange={newValue => onValueChange(newValue as string)}
selectionMode="single"
>
<Button variant="outline" startIcon={<SortIcon />} color={color}>
<Trans {...selectedOption.label} />
</Button>
<Menu>
{SortOptions.filter(
option => option.value !== 'reports_count:desc' || showReportsItem
).map(option => (
<MenuItem value={option.value} key={option.value}>
<Trans {...option.label} />
</MenuItem>
))}
</Menu>
</MenuTrigger>
);
}

View File

@@ -0,0 +1,158 @@
import {useReviews} from '@app/reviews/requests/use-reviews';
import {Reviewable} from '@app/reviews/reviewable';
import {
NewReviewForm,
NewReviewFormActions,
} from '@app/reviews/review-list/new-review-form';
import React, {ReactNode, useRef} from 'react';
import {ReviewListItem} from '@app/reviews/review-list/review-list-item';
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {Review} from '@app/titles/models/review';
import {AccountRequiredCard} from '@common/comments/comment-list/account-required-card';
import {useAuth} from '@common/auth/use-auth';
import {message} from '@common/i18n/message';
const accountRequiredMessage = message(
'Please <l>login</l> or <r>create account</r> to add a review',
);
interface Props {
reviewable: Reviewable;
disabled?: boolean;
noResultsMessage?: ReactNode;
showAccountRequiredMessage?: boolean;
}
export function ReviewList({
reviewable,
disabled,
noResultsMessage,
showAccountRequiredMessage,
}: Props) {
const query = useReviews(reviewable);
const actionsRef = useRef<NewReviewFormActions>(null);
const {user} = useAuth();
const currentUserReview = query.data?.pages[0].current_user_review;
const sharedReview = query.data?.pages[0].shared_review;
return (
<div>
<NewReviewForm
className="mb-14 md:-mx-14"
reviewable={reviewable}
currentReview={currentUserReview}
ref={actionsRef}
disabled={disabled}
/>
<div>
{showAccountRequiredMessage && (
<AccountRequiredCard message={accountRequiredMessage} />
)}
<AnimatePresence initial={false} mode="wait">
{query.isLoading ? (
<ReviewListSkeletons count={4} />
) : (
<ReviewListItems
reviews={query.items}
sharedReview={sharedReview}
noResultsMessage={noResultsMessage}
/>
)}
</AnimatePresence>
<div className="ml-84">
<InfiniteScrollSentinel
query={query}
variant="loadMore"
loaderMarginTop="mt-14"
loadMoreExtraContent={
<Button
variant="flat"
color="primary"
disabled={!user}
onClick={() => {
actionsRef.current?.openReviewPanel();
}}
>
<Trans message="Add a review" />
</Button>
}
/>
</div>
</div>
</div>
);
}
interface ReviewListItemsProps {
reviews: Review[];
sharedReview?: Review;
noResultsMessage?: ReactNode;
}
function ReviewListItems({
reviews,
sharedReview,
noResultsMessage,
}: ReviewListItemsProps) {
const {user} = useAuth();
let content: ReactNode;
if (!reviews.length) {
content = user
? noResultsMessage || (
<IllustratedMessage
className="mt-24"
size="sm"
title={<Trans message="Seems a little quiet over here" />}
description={<Trans message="Be the first to leave a review" />}
/>
)
: null;
} else {
content = reviews.map(review => (
<ReviewListItem key={review.id} review={review} />
));
}
return (
<m.div key="reviews" {...opacityAnimation}>
{sharedReview && <ReviewListItem review={sharedReview} isShared />}
{content}
</m.div>
);
}
interface ReviewListSkeletonsProps {
count: number;
}
export function ReviewListSkeletons({count}: ReviewListSkeletonsProps) {
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-[212px] group"
>
<Skeleton variant="avatar" radius="rounded-full" size="w-60 h-60" />
<div className="flex-auto text-sm">
<Skeleton
className="text-base font-medium max-w-200 mb-4"
variant="text"
/>
<Skeleton variant="text" className="max-w-60 mb-8 mt-10 text-lg" />
<Skeleton variant="text" className="mb-8 text-base max-w-240" />
<Skeleton className="text-sm" variant="text" />
<Skeleton className="text-sm" variant="text" />
<Skeleton className="text-xs mt-16" variant="text" />
</div>
</div>
))}
</m.div>
);
}

View File

@@ -0,0 +1,62 @@
import {useState} from 'react';
import {IconButton} from '@common/ui/buttons/icon-button';
import {StarIcon} from '@common/icons/material/Star';
import {StarBorderIcon} from '@common/icons/material/StarBorder';
import clsx from 'clsx';
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
interface Props {
count: number;
value: number;
onValueChange?: (value: number) => void;
className?: string;
readonly?: boolean;
}
export function StarSelector({
count,
value,
onValueChange,
className,
readonly,
}: Props) {
const isMobile = useIsMobileMediaQuery();
const [hoverRating, setHoverRating] = useState(value);
const {trans} = useTrans();
return (
<div
className={clsx('flex items-center', className)}
onPointerLeave={() => {
if (!readonly) {
setHoverRating(value);
}
}}
>
{Array.from({length: count}).map((_, i) => {
const number = i + 1;
const isActive = hoverRating >= number;
return (
<IconButton
key={i}
size={isMobile ? 'xs' : 'sm'}
aria-label={trans(
message('Rate :count stars', {values: {count: number}})
)}
iconSize="md"
color={isActive ? 'primary' : undefined}
disabled={readonly}
onClick={() => {
onValueChange?.(number);
}}
onPointerEnter={() => {
setHoverRating(number);
}}
>
{isActive ? <StarIcon /> : <StarBorderIcon />}
</IconButton>
);
})}
</div>
);
}