58
resources/client/admin/reviews/delete-reviews-button.tsx
Executable file
58
resources/client/admin/reviews/delete-reviews-button.tsx
Executable file
@@ -0,0 +1,58 @@
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import React from 'react';
|
||||
import {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';
|
||||
import {ButtonSize} from '@common/ui/buttons/button-size';
|
||||
import {useDeleteReviews} from '@app/reviews/requests/use-delete-reviews';
|
||||
|
||||
interface Props {
|
||||
reviewIds: number[];
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
export function DeleteReviewsButton({
|
||||
reviewIds,
|
||||
variant = 'outline',
|
||||
size = 'xs',
|
||||
}: Props) {
|
||||
const deleteReviews = useDeleteReviews();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={isConfirmed => {
|
||||
if (isConfirmed) {
|
||||
deleteReviews.mutate({reviewIds});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
color="danger"
|
||||
className="mr-10"
|
||||
disabled={deleteReviews.isPending}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={
|
||||
<Trans
|
||||
message="Delete [one review|other :count reviews]"
|
||||
values={{count: reviewIds.length}}
|
||||
/>
|
||||
}
|
||||
body={
|
||||
reviewIds.length > 1 ? (
|
||||
<Trans message="Are you sure you want to delete selected reviews?" />
|
||||
) : (
|
||||
<Trans message="Are you sure you want to delete this review?" />
|
||||
)
|
||||
}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
41
resources/client/admin/reviews/requests/use-update-review.ts
Executable file
41
resources/client/admin/reviews/requests/use-update-review.ts
Executable file
@@ -0,0 +1,41 @@
|
||||
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 {UseFormReturn} from 'react-hook-form';
|
||||
import {onFormQueryError} from '@common/errors/on-form-query-error';
|
||||
import {CreateReviewPayload} from '@app/reviews/requests/use-create-review';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
review: Review;
|
||||
}
|
||||
|
||||
export function useUpdateReview(
|
||||
review: Review,
|
||||
form?: UseFormReturn<CreateReviewPayload>,
|
||||
) {
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateReviewPayload) => updateReview(review, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['reviews']});
|
||||
toast(message('Review updated'));
|
||||
},
|
||||
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
|
||||
});
|
||||
}
|
||||
|
||||
function updateReview(
|
||||
review: Review,
|
||||
payload: CreateReviewPayload,
|
||||
): Promise<Response> {
|
||||
return apiClient
|
||||
.put(`reviews/${review.id}`, {
|
||||
score: payload.score,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
41
resources/client/admin/reviews/restore-comments-button.tsx
Executable file
41
resources/client/admin/reviews/restore-comments-button.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';
|
||||
import {ButtonSize} from '@common/ui/buttons/button-size';
|
||||
import {useRestoreComments} from '@common/comments/requests/use-restore-comments';
|
||||
|
||||
interface Props {
|
||||
commentIds: number[];
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
export function RestoreCommentsButton({
|
||||
commentIds,
|
||||
variant = 'outline',
|
||||
size = 'xs',
|
||||
}: Props) {
|
||||
const restoreComments = useRestoreComments();
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className="mr-10"
|
||||
disabled={restoreComments.isPending}
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
restoreComments.mutate(
|
||||
{commentIds},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trans message="Restore" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
246
resources/client/admin/reviews/review-datatable-item.tsx
Executable file
246
resources/client/admin/reviews/review-datatable-item.tsx
Executable file
@@ -0,0 +1,246 @@
|
||||
import {User} from '@common/auth/user';
|
||||
import React, {Fragment, useContext, useState} from 'react';
|
||||
import {Checkbox} from '@common/ui/forms/toggle/checkbox';
|
||||
import {UserAvatar} from '@common/ui/images/user-avatar';
|
||||
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
|
||||
import {queryClient} from '@common/http/query-client';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {SiteConfigContext} from '@common/core/settings/site-config-context';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {LinkStyle} from '@common/ui/buttons/external-link';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {Review} from '@app/titles/models/review';
|
||||
import {TitleRating} from '@app/reviews/title-rating';
|
||||
import {useUpdateReview} from '@app/admin/reviews/requests/use-update-review';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {CreateReviewPayload} from '@app/reviews/requests/use-create-review';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {StarSelector} from '@app/reviews/review-list/star-selector';
|
||||
import {DeleteReviewsButton} from '@app/admin/reviews/delete-reviews-button';
|
||||
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
|
||||
|
||||
interface Props {
|
||||
review: Review;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
export function ReviewDatatableItem({review, isSelected, onToggle}: Props) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const helpfulCount = review.helpful_count || 1;
|
||||
const totalFeedbackCount =
|
||||
review.helpful_count + review.not_helpful_count || 1;
|
||||
|
||||
return (
|
||||
<div className="border-b p-14">
|
||||
{review.reviewable && (
|
||||
<ReviewableHeader
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
reviewable={review.reviewable}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-start gap-10 pt-14 md:pl-20">
|
||||
<UserAvatar className="flex-shrink-0" user={review.user} size="md" />
|
||||
<div className="min-w-0 flex-auto overflow-hidden">
|
||||
<ReviewHeader review={review} />
|
||||
{isEditing ? (
|
||||
<EditReviewForm
|
||||
review={review}
|
||||
onClose={isSaved => {
|
||||
setIsEditing(false);
|
||||
if (isSaved) {
|
||||
queryClient.invalidateQueries({queryKey: ['comment']});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className="my-14">
|
||||
<TitleRating className="mb-8" 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-8 text-xs text-muted">
|
||||
<BulletSeparatedItems>
|
||||
<Trans
|
||||
message=":helpfulCount out of :total people found this helpful"
|
||||
values={{helpfulCount, total: totalFeedbackCount}}
|
||||
/>
|
||||
{review.reports_count ? (
|
||||
<Trans
|
||||
message=":count reports"
|
||||
values={{count: review.reports_count || 0}}
|
||||
/>
|
||||
) : null}
|
||||
</BulletSeparatedItems>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<DeleteReviewsButton reviewIds={[review.id]} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<Trans message="Edit" />
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewableHeaderProps {
|
||||
isSelected: boolean;
|
||||
onToggle: Props['onToggle'];
|
||||
reviewable: NormalizedModel;
|
||||
}
|
||||
function ReviewableHeader({
|
||||
isSelected,
|
||||
onToggle,
|
||||
reviewable,
|
||||
}: ReviewableHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="mr-14">
|
||||
<Checkbox checked={isSelected} onChange={() => onToggle()} />
|
||||
</div>
|
||||
{reviewable.image && (
|
||||
<img
|
||||
className="mr-6 h-20 w-20 overflow-hidden rounded object-cover"
|
||||
src={reviewable.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="mr-4 text-sm">{reviewable.name}</div>
|
||||
<div className="text-xs text-muted">({reviewable.model_type})</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentHeaderProps {
|
||||
review: Review;
|
||||
}
|
||||
function ReviewHeader({review}: CommentHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div>
|
||||
{review.user && (
|
||||
<UserDisplayName user={review.user} show="display_name" />
|
||||
)}
|
||||
</div>
|
||||
<div>•</div>
|
||||
<time>
|
||||
<FormattedRelativeTime date={review.created_at} />
|
||||
</time>
|
||||
{review.user && (
|
||||
<div className="ml-auto hidden md:block">
|
||||
{<UserDisplayName user={review.user} show="email" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditReviewFormProps {
|
||||
review: Review;
|
||||
onClose: (saved: boolean) => void;
|
||||
}
|
||||
function EditReviewForm({review, onClose}: EditReviewFormProps) {
|
||||
const [content, setContent] = useState(review.body);
|
||||
const updateReview = useUpdateReview(review);
|
||||
const form = useForm<CreateReviewPayload>({
|
||||
defaultValues: {
|
||||
score: review.score,
|
||||
title: review.title,
|
||||
body: review.body,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Form
|
||||
className="mt-24"
|
||||
form={form}
|
||||
onSubmit={newValues => {
|
||||
updateReview.mutate(newValues, {onSuccess: () => onClose(true)});
|
||||
}}
|
||||
>
|
||||
<StarSelector
|
||||
className="-ml-8 mb-12"
|
||||
count={10}
|
||||
value={form.watch('score')}
|
||||
onValueChange={newScore => {
|
||||
form.setValue('score', newScore);
|
||||
}}
|
||||
/>
|
||||
<FormTextField
|
||||
name="title"
|
||||
className="mb-24"
|
||||
label={<Trans message="Title" />}
|
||||
labelSuffix={<Trans message="10 character minimum" />}
|
||||
autoFocus
|
||||
minLength={10}
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
className="mb-24"
|
||||
name="body"
|
||||
label={<Trans message="Review" />}
|
||||
labelSuffix={<Trans message="100 character minimum" />}
|
||||
inputElementType="textarea"
|
||||
rows={5}
|
||||
minLength={100}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
color="primary"
|
||||
type="submit"
|
||||
className="mr-6"
|
||||
disabled={updateReview.isPending}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
className="mr-6"
|
||||
onClick={e => onClose(false)}
|
||||
disabled={updateReview.isPending}
|
||||
>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface UserDisplayNameProps {
|
||||
user: User;
|
||||
show: 'display_name' | 'email';
|
||||
}
|
||||
function UserDisplayName({user, show}: UserDisplayNameProps) {
|
||||
const {auth} = useContext(SiteConfigContext);
|
||||
if (auth.getUserProfileLink) {
|
||||
return (
|
||||
<Link
|
||||
to={auth.getUserProfileLink(user)}
|
||||
className={LinkStyle}
|
||||
target="_blank"
|
||||
>
|
||||
{user[show]}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className="text-muted">{user[show]}</div>;
|
||||
}
|
||||
89
resources/client/admin/reviews/reviews-datatable-filters.tsx
Executable file
89
resources/client/admin/reviews/reviews-datatable-filters.tsx
Executable file
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
ALL_PRIMITIVE_OPERATORS,
|
||||
BackendFilter,
|
||||
FilterControlType,
|
||||
FilterOperator,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {USER_MODEL} from '@common/auth/user';
|
||||
import {
|
||||
createdAtFilter,
|
||||
updatedAtFilter,
|
||||
} from '@common/datatable/filters/timestamp-filters';
|
||||
import {TITLE_MODEL} from '@app/titles/models/title';
|
||||
|
||||
export const ReviewsDatatableFilters: BackendFilter[] = [
|
||||
{
|
||||
key: 'user_id',
|
||||
label: message('User'),
|
||||
description: message('User review was created by'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: USER_MODEL,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'reviewable_id',
|
||||
label: message('Title'),
|
||||
description: message('Movie or series review was created for'),
|
||||
defaultOperator: FilterOperator.eq,
|
||||
extraFilters: [
|
||||
{
|
||||
key: 'reviewable_type',
|
||||
operator: FilterOperator.eq,
|
||||
value: 'App\\Title',
|
||||
},
|
||||
],
|
||||
control: {
|
||||
type: FilterControlType.SelectModel,
|
||||
model: TITLE_MODEL,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
label: message('Score'),
|
||||
description: message('Review score'),
|
||||
defaultOperator: FilterOperator.gte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
maxValue: 10,
|
||||
defaultValue: 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'helpful_count',
|
||||
label: message('Helpful count'),
|
||||
description: message('How many users found this review helpful'),
|
||||
defaultOperator: FilterOperator.gte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
defaultValue: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'not_helpful_count',
|
||||
label: message('Not helpful count'),
|
||||
description: message('How many users found this review not helpful'),
|
||||
defaultOperator: FilterOperator.gte,
|
||||
operators: ALL_PRIMITIVE_OPERATORS,
|
||||
control: {
|
||||
type: FilterControlType.Input,
|
||||
inputType: 'number',
|
||||
minValue: 1,
|
||||
defaultValue: 10,
|
||||
},
|
||||
},
|
||||
createdAtFilter({
|
||||
description: message('Date review was created'),
|
||||
}),
|
||||
updatedAtFilter({
|
||||
description: message('Date review was last updated'),
|
||||
}),
|
||||
];
|
||||
162
resources/client/admin/reviews/reviews-datatable-page.tsx
Executable file
162
resources/client/admin/reviews/reviews-datatable-page.tsx
Executable file
@@ -0,0 +1,162 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Trans } from "@common/i18n/trans";
|
||||
import clsx from "clsx";
|
||||
import { StaticPageTitle } from "@common/seo/static-page-title";
|
||||
import { DataTableHeader } from "@common/datatable/data-table-header";
|
||||
import {
|
||||
useBackendFilterUrlParams
|
||||
} from "@common/datatable/filters/backend-filter-url-params";
|
||||
import {
|
||||
GetDatatableDataParams,
|
||||
useDatatableData
|
||||
} from "@common/datatable/requests/paginated-resources";
|
||||
import { FilterList } from "@common/datatable/filters/filter-list/filter-list";
|
||||
import {
|
||||
SelectedStateDatatableHeader
|
||||
} from "@common/datatable/selected-state-datatable-header";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
DataTablePaginationFooter
|
||||
} from "@common/datatable/data-table-pagination-footer";
|
||||
import {
|
||||
DataTableEmptyStateMessage
|
||||
} from "@common/datatable/page/data-table-emty-state-message";
|
||||
import reviewsImage from "./reviews.svg";
|
||||
import { FullPageLoader } from "@common/ui/progress/full-page-loader";
|
||||
import { Review } from "@app/titles/models/review";
|
||||
import { DeleteReviewsButton } from "@app/admin/reviews/delete-reviews-button";
|
||||
import { ReviewDatatableItem } from "@app/admin/reviews/review-datatable-item";
|
||||
import {
|
||||
ReviewsDatatableFilters
|
||||
} from "@app/admin/reviews/reviews-datatable-filters";
|
||||
import {
|
||||
ReviewListSortButton
|
||||
} from "@app/reviews/review-list/review-list-sort-button";
|
||||
import { Reviewable } from "@app/reviews/reviewable";
|
||||
|
||||
interface Props {
|
||||
hideTitle?: boolean;
|
||||
reviewable?: Reviewable;
|
||||
}
|
||||
export function ReviewsDatatablePage({hideTitle, reviewable}: Props) {
|
||||
const filters = useMemo(() => {
|
||||
return ReviewsDatatableFilters.filter(
|
||||
f => f.key !== 'reviewable_id' || !reviewable,
|
||||
);
|
||||
}, [reviewable]);
|
||||
const {encodedFilters} = useBackendFilterUrlParams(filters);
|
||||
const [params, setParams] = useState<GetDatatableDataParams>({perPage: 15});
|
||||
const [selectedReviews, setSelectedReviews] = useState<number[]>([]);
|
||||
const [sort, setSort] = useState<string>('created_at:desc');
|
||||
const [orderBy, orderDir] = sort.split(':');
|
||||
|
||||
const query = useDatatableData<Review>('reviews', {
|
||||
...params,
|
||||
orderBy,
|
||||
orderDir: orderDir as 'asc' | 'desc',
|
||||
with: 'reviewable,user',
|
||||
filters: encodedFilters,
|
||||
reviewable_type: reviewable?.model_type,
|
||||
reviewable_id: reviewable?.id,
|
||||
}, undefined, () => {
|
||||
setSelectedReviews([]);
|
||||
});
|
||||
|
||||
const toggleReview = useCallback(
|
||||
(id: number) => {
|
||||
const newValues = [...selectedReviews];
|
||||
if (!newValues.includes(id)) {
|
||||
newValues.push(id);
|
||||
} else {
|
||||
const index = newValues.indexOf(id);
|
||||
newValues.splice(index, 1);
|
||||
}
|
||||
setSelectedReviews(newValues);
|
||||
},
|
||||
[selectedReviews, setSelectedReviews],
|
||||
);
|
||||
|
||||
const isFiltering = !!(params.query || params.filters || encodedFilters);
|
||||
const pagination = query.data?.pagination;
|
||||
|
||||
return (
|
||||
<div className={clsx(!hideTitle && 'p-12 md:p-24')}>
|
||||
<div className={clsx('mb-16')}>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Reviews" />
|
||||
</StaticPageTitle>
|
||||
{!hideTitle && (
|
||||
<h1 className="text-3xl font-light">
|
||||
<Trans message="Reviews" />
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{selectedReviews.length ? (
|
||||
<SelectedStateDatatableHeader
|
||||
selectedItemsCount={selectedReviews.length}
|
||||
actions={
|
||||
<DeleteReviewsButton
|
||||
size="sm"
|
||||
variant="flat"
|
||||
reviewIds={selectedReviews}
|
||||
/>
|
||||
}
|
||||
key="selected"
|
||||
/>
|
||||
) : (
|
||||
<DataTableHeader
|
||||
key="default"
|
||||
filters={filters}
|
||||
searchValue={params.query}
|
||||
onSearchChange={query => setParams({...params, query})}
|
||||
actions={
|
||||
<ReviewListSortButton
|
||||
value={sort}
|
||||
onValueChange={newSort => setSort(newSort)}
|
||||
color="primary"
|
||||
showReportsItem
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<FilterList className="mb-14" filters={filters} />
|
||||
|
||||
{query.isLoading ? (
|
||||
<FullPageLoader className="min-h-200" />
|
||||
) : (
|
||||
<div className="border-x border-t rounded">
|
||||
{pagination?.data.map(review => (
|
||||
<ReviewDatatableItem
|
||||
key={review.id}
|
||||
review={review}
|
||||
isSelected={selectedReviews.includes(review.id)}
|
||||
onToggle={() => toggleReview(review.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(query.isFetched || query.isPlaceholderData) &&
|
||||
!pagination?.data.length ? (
|
||||
<DataTableEmptyStateMessage
|
||||
className="pt-50"
|
||||
isFiltering={isFiltering}
|
||||
image={reviewsImage}
|
||||
title={<Trans message="No reviews have been created yet" />}
|
||||
filteringTitle={<Trans message="No matching reviews" />}
|
||||
/>
|
||||
) : undefined}
|
||||
|
||||
<DataTablePaginationFooter
|
||||
className="mt-10"
|
||||
query={query}
|
||||
onPageChange={page => setParams({...params, page})}
|
||||
onPerPageChange={perPage => setParams({...params, perPage})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
resources/client/admin/reviews/reviews.svg
Executable file
1
resources/client/admin/reviews/reviews.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
40
resources/client/admin/reviews/title-filter/title-filter-control.tsx
Executable file
40
resources/client/admin/reviews/title-filter/title-filter-control.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
import {CustomFilterControl} from '@common/datatable/filters/backend-filter';
|
||||
import {Fragment} from 'react';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
import {Avatar} from '@common/ui/images/avatar';
|
||||
import {FilterListItemDialogTrigger} from '@common/datatable/filters/filter-list/filter-list-item-dialog-trigger';
|
||||
import {FilterListControlProps} from '@common/datatable/filters/filter-list/filter-list-control';
|
||||
import {useNormalizedModel} from '@common/users/queries/use-normalized-model';
|
||||
|
||||
export function TitleFilterControl(
|
||||
props: FilterListControlProps<number, CustomFilterControl>,
|
||||
) {
|
||||
const {value, filter} = props;
|
||||
const {isLoading, data} = useNormalizedModel(
|
||||
`normalized-models/title/${value}`,
|
||||
);
|
||||
|
||||
const skeleton = (
|
||||
<Fragment>
|
||||
<Skeleton variant="avatar" size="w-18 h-18 mr-6" />
|
||||
<Skeleton variant="rect" size="w-50" />
|
||||
</Fragment>
|
||||
);
|
||||
const modelPreview = (
|
||||
<Fragment>
|
||||
<Avatar size="xs" src={data?.model.image} className="mr-6" />
|
||||
{data?.model.name}
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
const label = isLoading || !data ? skeleton : modelPreview;
|
||||
|
||||
const Panel = filter.control.panel;
|
||||
return (
|
||||
<FilterListItemDialogTrigger
|
||||
{...props}
|
||||
label={label}
|
||||
panel={<Panel filter={filter} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
resources/client/admin/reviews/title-filter/title-filter-panel.tsx
Executable file
19
resources/client/admin/reviews/title-filter/title-filter-panel.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
BackendFilter,
|
||||
CustomFilterControl,
|
||||
} from '@common/datatable/filters/backend-filter';
|
||||
import React from 'react';
|
||||
import {TitleSelect} from '@app/titles/title-select';
|
||||
|
||||
interface Props {
|
||||
filter: BackendFilter<CustomFilterControl>;
|
||||
}
|
||||
export function TitleFilterPanel({filter}: Props) {
|
||||
return (
|
||||
<TitleSelect
|
||||
name={`${filter.key}.value`}
|
||||
seasonName={`${filter.key}.season`}
|
||||
episodeName={`${filter.key}.episode`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user