119
resources/client/reviews/interactable-rating.tsx
Executable file
119
resources/client/reviews/interactable-rating.tsx
Executable 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';
|
||||
}
|
||||
}
|
||||
138
resources/client/reviews/rating-dialog.tsx
Executable file
138
resources/client/reviews/rating-dialog.tsx
Executable 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" />;
|
||||
}
|
||||
}
|
||||
45
resources/client/reviews/requests/use-create-review.ts
Executable file
45
resources/client/reviews/requests/use-create-review.ts
Executable 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);
|
||||
}
|
||||
36
resources/client/reviews/requests/use-current-user-ratings.ts
Executable file
36
resources/client/reviews/requests/use-current-user-ratings.ts
Executable 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);
|
||||
}
|
||||
27
resources/client/reviews/requests/use-delete-reviews.ts
Executable file
27
resources/client/reviews/requests/use-delete-reviews.ts
Executable 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);
|
||||
}
|
||||
46
resources/client/reviews/requests/use-reviews.ts
Executable file
46
resources/client/reviews/requests/use-reviews.ts
Executable 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'),
|
||||
},
|
||||
});
|
||||
}
|
||||
33
resources/client/reviews/requests/use-submit-review-feedback.ts
Executable file
33
resources/client/reviews/requests/use-submit-review-feedback.ts
Executable 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);
|
||||
}
|
||||
178
resources/client/reviews/review-list/new-review-form.tsx
Executable file
178
resources/client/reviews/review-list/new-review-form.tsx
Executable 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
66
resources/client/reviews/review-list/review-list-sort-button.tsx
Executable file
66
resources/client/reviews/review-list/review-list-sort-button.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
158
resources/client/reviews/review-list/review-list.tsx
Executable file
158
resources/client/reviews/review-list/review-list.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
62
resources/client/reviews/review-list/star-selector.tsx
Executable file
62
resources/client/reviews/review-list/star-selector.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
4
resources/client/reviews/reviewable.ts
Executable file
4
resources/client/reviews/reviewable.ts
Executable file
@@ -0,0 +1,4 @@
|
||||
export interface Reviewable {
|
||||
id: number;
|
||||
model_type: string;
|
||||
}
|
||||
21
resources/client/reviews/title-rating.tsx
Executable file
21
resources/client/reviews/title-rating.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user