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,119 @@
import {Title} from '@app/titles/models/title';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Button} from '@common/ui/buttons/button';
import {StarBorderIcon} from '@common/icons/material/StarBorder';
import {Trans} from '@common/i18n/trans';
import {RatingDialog} from '@app/reviews/rating-dialog';
import {Episode} from '@app/titles/models/episode';
import clsx from 'clsx';
import {TitleRating} from '@app/reviews/title-rating';
import {useCurrentUserRatingFor} from '@app/reviews/requests/use-current-user-ratings';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {AnimatePresence, m} from 'framer-motion';
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
import {ReactElement} from 'react';
import {useAuthClickCapture} from '@app/use-auth-click-capture';
interface Props {
title: Title;
episode?: Episode;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function InteractableRating({
title,
episode,
size = 'md',
className,
}: Props) {
const isUpcoming = episode
? episode.status === 'upcoming'
: title.status === 'upcoming';
const score = (episode || title).rating;
if (isUpcoming) {
return null;
}
return (
<div
className={clsx(
'flex min-w-[249px] flex-shrink-0 items-center',
getSizeClassName(size),
className
)}
>
{score ? (
<div className="border-r pr-14">
<TitleRating score={score} />
</div>
) : null}
<RateButton title={title} episode={episode} />
</div>
);
}
function RateButton({title, episode}: Props) {
const item = episode || title;
const {isLoading, rating} = useCurrentUserRatingFor(item);
const authHandler = useAuthClickCapture();
let content: ReactElement;
if (isLoading) {
content = (
<m.div
key="skeleton"
{...opacityAnimation}
className="flex min-h-36 items-center"
>
<Skeleton variant="rect" size="w-[106px] h-16 ml-14" />
</m.div>
);
} else {
content = (
<m.div key="button" {...opacityAnimation}>
<DialogTrigger type="modal">
{rating ? (
<Button onClickCapture={authHandler}>
<Trans
message="Your rating: :value / 10"
values={{value: rating.score}}
/>
</Button>
) : (
<Button
onClickCapture={authHandler}
startIcon={<StarBorderIcon />}
className="min-w-120"
>
<Trans message="Rate this" />
</Button>
)}
<RatingDialog
title={title}
episode={episode}
initialRating={rating}
/>
</DialogTrigger>
</m.div>
);
}
return (
<AnimatePresence initial={false} mode="wait">
{content}
</AnimatePresence>
);
}
function getSizeClassName(size: Props['size']) {
switch (size) {
case 'sm':
return 'text-sm';
case 'md':
return 'text-base';
case 'lg':
return 'text-lg';
}
}

View File

@@ -0,0 +1,138 @@
import {Title} from '@app/titles/models/title';
import {Episode, EPISODE_MODEL} from '@app/titles/models/episode';
import {Dialog} from '@common/ui/overlays/dialog/dialog';
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
import {Trans} from '@common/i18n/trans';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {useState} from 'react';
import {Button} from '@common/ui/buttons/button';
import {useCreateReview} from '@app/reviews/requests/use-create-review';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {useDeleteReviews} from '@app/reviews/requests/use-delete-reviews';
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
import {TitleLink} from '@app/titles/title-link';
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
import {StarSelector} from '@app/reviews/review-list/star-selector';
interface Props {
title: Title;
episode?: Episode;
initialRating?: {id: number; score: number};
}
export function RatingDialog({title, episode, initialRating}: Props) {
const item = episode || title;
const createReview = useCreateReview();
const deleteReview = useDeleteReviews();
const {close} = useDialogContext();
const [currentRating, setCurrentRating] = useState(initialRating?.score || 0);
const handleCreateReview = () => {
if (currentRating) {
createReview.mutate(
{reviewable: item, score: currentRating},
{
onSuccess: () => close(),
},
);
}
};
const handleDeleteReview = () => {
if (initialRating) {
deleteReview.mutate(
{reviewIds: [initialRating.id]},
{
onSuccess: () => close(),
},
);
}
};
return (
<Dialog size="w-auto">
<DialogHeader>
<DialogTitle item={item} />
</DialogHeader>
<DialogBody>
{item.model_type === EPISODE_MODEL ? (
<EpisodeDetails title={title} episode={episode!} />
) : (
<TitleDetails title={title} />
)}
<div className="pb-16">
<StarSelector
count={10}
value={currentRating}
onValueChange={setCurrentRating}
className="my-14"
/>
<Button
variant="flat"
color="primary"
className="w-full"
disabled={!currentRating || createReview.isPending}
onClick={handleCreateReview}
>
<Trans message="Rate" />
</Button>
{initialRating && (
<Button
className="w-full mt-14"
disabled={deleteReview.isPending}
onClick={handleDeleteReview}
>
<Trans message="Remove rating" />
</Button>
)}
</div>
</DialogBody>
</Dialog>
);
}
interface TitleDetailsProps {
title: Title;
}
function TitleDetails({title}: TitleDetailsProps) {
return (
<div className="flex items-center gap-12 mb-24">
<TitlePoster size="w-60" srcSize="sm" title={title} />
<div className="text-sm">
<div>{title.name}</div>
<div>{title.year}</div>
</div>
</div>
);
}
interface EpisodeDetailsProps {
title: Title;
episode: Episode;
}
function EpisodeDetails({title, episode}: EpisodeDetailsProps) {
return (
<div className="flex items-center gap-12 mb-24">
<EpisodePoster size="w-100" title={title} episode={episode} />
<div className="text-base">
<TitleLink title={title} color="primary" />
<div className="text-sm">
{episode.name} (<CompactSeasonEpisode episode={episode} />)
</div>
</div>
</div>
);
}
interface DialogTitleProps {
item: Title | Episode;
}
function DialogTitle({item}: DialogTitleProps) {
if (item.model_type === EPISODE_MODEL) {
return <Trans message="Rate this episode" />;
} else if (item.is_series) {
return <Trans message="Rate this series" />;
} else {
return <Trans message="Rate this movie" />;
}
}

View File

@@ -0,0 +1,45 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {Review} from '@app/titles/models/review';
import {Reviewable} from '@app/reviews/reviewable';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {reviewsQueryKey} from '@app/reviews/requests/use-reviews';
interface Response extends BackendResponse {
review: Review;
}
export interface CreateReviewPayload {
score: number;
title?: string;
body?: string;
}
interface Payload extends CreateReviewPayload {
reviewable: Reviewable;
}
export function useCreateReview(form?: UseFormReturn<CreateReviewPayload>) {
return useMutation({
mutationFn: (payload: Payload) => createReview(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: reviewsQueryKey()});
},
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
});
}
function createReview(payload: Payload): Promise<Response> {
return apiClient
.post(`reviews`, {
reviewable_id: payload.reviewable.id,
reviewable_type: payload.reviewable.model_type,
score: payload.score,
title: payload.title,
body: payload.body,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,36 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useAuth} from '@common/auth/use-auth';
import {Title} from '@app/titles/models/title';
import {Episode} from '@app/titles/models/episode';
interface Response extends BackendResponse {
ratings: {
episode: Record<number, {id: number; score: number}>;
title: Record<number, {id: number; score: number}>;
};
}
export function useCurrentUserRatings() {
const {user} = useAuth();
return useQuery({
queryKey: ['reviews', 'users', `${user?.id}`],
queryFn: () => fetchRatings(),
enabled: !!user,
});
}
export function useCurrentUserRatingFor(item: Title | Episode) {
const query = useCurrentUserRatings();
return {
isLoading: query.isLoading && query.fetchStatus !== 'idle',
rating: query.data?.ratings?.[item.model_type]?.[item.id],
};
}
function fetchRatings() {
return apiClient
.get<Response>(`users/me/ratings`)
.then(response => response.data);
}

View File

@@ -0,0 +1,27 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {reviewsQueryKey} from '@app/reviews/requests/use-reviews';
interface Response extends BackendResponse {
//
}
interface Payload {
reviewIds: number[];
}
export function useDeleteReviews() {
return useMutation({
mutationFn: (payload: Payload) => deleteReviews(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: reviewsQueryKey()});
},
onError: r => showHttpErrorToast(r),
});
}
function deleteReviews({reviewIds}: Payload): Promise<Response> {
return apiClient.delete(`reviews/${reviewIds.join(',')}`).then(r => r.data);
}

View File

@@ -0,0 +1,46 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {Reviewable} from '@app/reviews/reviewable';
import {Review} from '@app/titles/models/review';
import {useLocalStorage} from '@common/utils/hooks/local-storage';
import {useSearchParams} from 'react-router-dom';
export interface UseReviewAdditionalData {
current_user_review?: Review;
shared_review?: Review;
}
export function reviewsQueryKey(
reviewable?: Reviewable,
params?: Record<string, any>,
) {
const key: any[] = ['reviews'];
if (reviewable) {
key.push(`${reviewable.id}-${reviewable.model_type}`);
}
if (params) {
key.push(params);
}
return key;
}
export function useReviews(reviewable: Reviewable) {
const [searchParams] = useSearchParams();
const [sort] = useLocalStorage(
`reviewSort.${reviewable.model_type}`,
'created_at:desc',
);
const [defaultOrderBy, defaultOrderDir] = sort.split(':');
return useInfiniteData<Review, UseReviewAdditionalData>({
willSortOrFilter: true,
queryKey: reviewsQueryKey(reviewable, {sort}),
endpoint: 'reviewable/reviews',
defaultOrderBy,
defaultOrderDir: defaultOrderDir as 'asc' | 'desc',
queryParams: {
reviewable_type: reviewable.model_type,
reviewable_id: reviewable.id,
perPage: 5,
sharedReviewId: searchParams.get('reviewId'),
},
});
}

View File

@@ -0,0 +1,33 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {Review} from '@app/titles/models/review';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {
review: Review;
}
interface Payload {
isHelpful: boolean;
}
export function useSubmitReviewFeedback(review: Review) {
return useMutation({
mutationFn: (payload: Payload) => submitFeedback(payload, review),
onSuccess: () => {
toast(message('Feedback submitted'));
},
onError: r => showHttpErrorToast(r),
});
}
function submitFeedback(payload: Payload, review: Review): Promise<Response> {
return apiClient
.post(`reviews/${review.id}/feedback`, {
is_helpful: payload.isHelpful,
})
.then(r => r.data);
}

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

View File

@@ -0,0 +1,4 @@
export interface Reviewable {
id: number;
model_type: string;
}

View File

@@ -0,0 +1,21 @@
import {StarIcon} from '@common/icons/material/Star';
import clsx from 'clsx';
interface Props {
score: number | null;
className?: string;
}
export function TitleRating({score, className}: Props) {
if (!score) return null;
return (
<div
className={clsx(
'flex items-center gap-4 flex-shrink-0 whitespace-nowrap',
className
)}
>
<StarIcon className="text-primary" />
<span>{score} / 10</span>
</div>
);
}