153
resources/client/admin/titles/title-editor/credits-editor/add-credit-dialog.tsx
Executable file
153
resources/client/admin/titles/title-editor/credits-editor/add-credit-dialog.tsx
Executable file
@@ -0,0 +1,153 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {useForm, useFormContext} from 'react-hook-form';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {
|
||||
CreateTitleCreditPayload,
|
||||
useCreateTitleCredit,
|
||||
} from '@app/admin/titles/requests/use-create-title-credit';
|
||||
import {FormNormalizedModelField} from '@common/ui/forms/normalized-model-field';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {UpdateTitleCreditPayload} from '@app/admin/titles/requests/use-update-title-credit';
|
||||
import {Fragment, useMemo} from 'react';
|
||||
import {FormSelect} from '@common/ui/forms/select/select';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
isCrew: boolean;
|
||||
}
|
||||
export function AddCreditDialog({isCrew}: Props) {
|
||||
const {formId, close} = useDialogContext();
|
||||
const form = useForm<CreateTitleCreditPayload>({
|
||||
defaultValues: {
|
||||
department: !isCrew ? 'actors' : undefined,
|
||||
job: !isCrew ? 'actor' : undefined,
|
||||
},
|
||||
});
|
||||
const createCredit = useCreateTitleCredit(form);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Create credit" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Form
|
||||
id={formId}
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
createCredit.mutate(values, {onSuccess: () => close()});
|
||||
}}
|
||||
>
|
||||
<FormNormalizedModelField
|
||||
endpoint="normalized-models/person"
|
||||
name="person_id"
|
||||
label={<Trans message="Person" />}
|
||||
className="mb-24"
|
||||
autoFocus
|
||||
/>
|
||||
<SharedCreditDialogFields isCrew={isCrew} />
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => close()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={createCredit.isPending}
|
||||
>
|
||||
<Trans message="Create" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface SharedCreditDialogFieldsProps {
|
||||
isCrew: boolean;
|
||||
}
|
||||
export function SharedCreditDialogFields({
|
||||
isCrew,
|
||||
}: SharedCreditDialogFieldsProps) {
|
||||
return (
|
||||
<Fragment>
|
||||
<FormTextField
|
||||
name="character"
|
||||
label={<Trans message="Character" />}
|
||||
required={!isCrew}
|
||||
className={clsx('mb-24', isCrew && 'hidden')}
|
||||
/>
|
||||
<CrewFields isCrew={isCrew} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface CrewFieldsProps {
|
||||
isCrew: boolean;
|
||||
}
|
||||
function CrewFields({isCrew}: CrewFieldsProps) {
|
||||
const {data} = useValueLists(['tmdbDepartments']);
|
||||
const {watch} = useFormContext<UpdateTitleCreditPayload>();
|
||||
const selectedDepartment = watch('department');
|
||||
const {jobs, departments} = useMemo(() => {
|
||||
const departments =
|
||||
data?.tmdbDepartments.map(d => ({
|
||||
department: d.department.toLowerCase(),
|
||||
jobs: d.jobs,
|
||||
})) || [];
|
||||
const department = departments.find(
|
||||
d => d.department === selectedDepartment,
|
||||
);
|
||||
const jobs = department?.jobs.map(job => ({job: job.toLowerCase()})) || [];
|
||||
return {
|
||||
jobs,
|
||||
departments,
|
||||
};
|
||||
}, [data, selectedDepartment]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FormSelect
|
||||
name="department"
|
||||
label={<Trans message="Department" />}
|
||||
required
|
||||
disabled={!isCrew}
|
||||
items={departments}
|
||||
className="mb-24"
|
||||
selectionMode="single"
|
||||
showSearchField
|
||||
>
|
||||
{item => (
|
||||
<Item value={item.department}>
|
||||
<Trans message={item.department} />
|
||||
</Item>
|
||||
)}
|
||||
</FormSelect>
|
||||
<FormSelect
|
||||
name="job"
|
||||
label={<Trans message="Job" />}
|
||||
required
|
||||
disabled={!isCrew}
|
||||
items={jobs}
|
||||
selectionMode="single"
|
||||
showSearchField
|
||||
>
|
||||
{item => (
|
||||
<Item value={item.job} key={item.job}>
|
||||
<Trans message={item.job} />
|
||||
</Item>
|
||||
)}
|
||||
</FormSelect>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
124
resources/client/admin/titles/title-editor/credits-editor/cast-editor-table.tsx
Executable file
124
resources/client/admin/titles/title-editor/credits-editor/cast-editor-table.tsx
Executable file
@@ -0,0 +1,124 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {TitleCredit} from '@app/titles/models/title';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DragHandleIcon} from '@common/icons/material/DragHandle';
|
||||
import {PersonPoster} from '@app/people/person-poster/person-poster';
|
||||
import React, {Fragment, useContext, useRef} from 'react';
|
||||
import {Table} from '@common/ui/tables/table';
|
||||
import {RowElementProps} from '@common/ui/tables/table-row';
|
||||
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
|
||||
import {useIsTouchDevice} from '@common/utils/hooks/is-touch-device';
|
||||
import {TableContext} from '@common/ui/tables/table-context';
|
||||
import {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';
|
||||
import {mergeProps} from '@react-aria/utils';
|
||||
import {DragPreview} from '@common/ui/interactions/dnd/drag-preview';
|
||||
import {useSortTitleCredits} from '@app/admin/titles/requests/use-sort-title-credits';
|
||||
import {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';
|
||||
import {getCreditsEditorActionColumn} from '@app/admin/titles/title-editor/credits-editor/get-credits-editor-action-column';
|
||||
import {UseInfiniteDataResult} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {CreditsTableQueryIndicator} from '@app/admin/titles/title-editor/credits-editor/credits-table-query-indicator';
|
||||
import {useSortable} from '@common/ui/interactions/dnd/sortable/use-sortable';
|
||||
|
||||
const columnConfig: ColumnConfig<TitleCredit>[] = [
|
||||
{
|
||||
key: 'dragHandle',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
header: () => <Trans message="Drag handle" />,
|
||||
hideHeader: true,
|
||||
body: () => (
|
||||
<DragHandleIcon className="cursor-pointer text-muted hover:text" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: () => <Trans message="Person" />,
|
||||
visibleInMode: 'all',
|
||||
body: credit => (
|
||||
<div className="flex items-center gap-12">
|
||||
<PersonPoster rounded person={credit} size="w-44" />
|
||||
<div className="min-w-0 overflow-hidden">{credit.name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'character',
|
||||
header: () => <Trans message="Character" />,
|
||||
body: credit => credit.pivot.character,
|
||||
},
|
||||
getCreditsEditorActionColumn(),
|
||||
];
|
||||
|
||||
interface Props {
|
||||
query: UseInfiniteDataResult<TitleCredit>;
|
||||
}
|
||||
export function CastEditorTable({query}: Props) {
|
||||
return (
|
||||
<Fragment>
|
||||
<Table
|
||||
enableSelection={false}
|
||||
columns={columnConfig}
|
||||
data={query.items}
|
||||
renderRowAs={CreditsTableRow}
|
||||
cellHeight="h-54"
|
||||
/>
|
||||
<CreditsTableQueryIndicator query={query} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditsTableRow({
|
||||
item,
|
||||
children,
|
||||
className,
|
||||
...domProps
|
||||
}: RowElementProps<TitleCredit>) {
|
||||
const isTouchDevice = useIsTouchDevice();
|
||||
const context = useContext(TableContext);
|
||||
const domRef = useRef<HTMLTableRowElement>(null);
|
||||
const previewRef = useRef<DragPreviewRenderer>(null);
|
||||
const credits = context.data as TitleCredit[];
|
||||
|
||||
const sortCredits = useSortTitleCredits();
|
||||
|
||||
const {sortableProps} = useSortable({
|
||||
ref: domRef,
|
||||
disabled: isTouchDevice ?? false,
|
||||
item,
|
||||
items: credits,
|
||||
type: 'cast-editor-item',
|
||||
preview: previewRef,
|
||||
strategy: 'line',
|
||||
onSortEnd: (oldIndex, newIndex) => {
|
||||
const ids = credits.map(item => item.pivot.id);
|
||||
const sortedIds = moveItemInNewArray(ids, oldIndex, newIndex);
|
||||
sortCredits.mutate({ids: sortedIds});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
ref={domRef}
|
||||
{...mergeProps(sortableProps, domProps)}
|
||||
>
|
||||
{children}
|
||||
{!item.isPlaceholder && <RowDragPreview item={item} ref={previewRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowDragPreviewProps {
|
||||
item: NormalizedModel;
|
||||
}
|
||||
const RowDragPreview = React.forwardRef<
|
||||
DragPreviewRenderer,
|
||||
RowDragPreviewProps
|
||||
>(({item}, ref) => {
|
||||
return (
|
||||
<DragPreview ref={ref}>
|
||||
{() => (
|
||||
<div className="rounded bg-chip p-8 text-sm shadow">{item.name}</div>
|
||||
)}
|
||||
</DragPreview>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {UseInfiniteDataResult} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {TitleCredit} from '@app/titles/models/title';
|
||||
import React from 'react';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {RecentActorsIcon} from '@common/icons/material/RecentActors';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {TitleEditorPageStatus} from '@app/admin/titles/title-editor/title-editor-page-status';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
|
||||
interface Props {
|
||||
query: UseInfiniteDataResult<TitleCredit>;
|
||||
}
|
||||
export function CreditsTableQueryIndicator({query}: Props) {
|
||||
if (query.data && !query.items.length) {
|
||||
return <NoCreditsMessage />;
|
||||
}
|
||||
|
||||
if (!query.data) {
|
||||
return <TitleEditorPageStatus query={query} />;
|
||||
}
|
||||
|
||||
return <InfiniteScrollSentinel query={query} />;
|
||||
}
|
||||
|
||||
function NoCreditsMessage() {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
imageMargin="mb-8"
|
||||
image={
|
||||
<div className="text-muted">
|
||||
<RecentActorsIcon size="xl" />
|
||||
</div>
|
||||
}
|
||||
imageHeight="h-auto"
|
||||
title={<Trans message="No credits have been added yet" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {TitleCredit} from '@app/titles/models/title';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {PersonPoster} from '@app/people/person-poster/person-poster';
|
||||
import React, {Fragment, useContext} from 'react';
|
||||
import {Table, TableBodyProps} from '@common/ui/tables/table';
|
||||
import {TableRow} from '@common/ui/tables/table-row';
|
||||
import {TableContext} from '@common/ui/tables/table-context';
|
||||
import {getCreditsEditorActionColumn} from '@app/admin/titles/title-editor/credits-editor/get-credits-editor-action-column';
|
||||
import {UseInfiniteDataResult} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {CreditsTableQueryIndicator} from '@app/admin/titles/title-editor/credits-editor/credits-table-query-indicator';
|
||||
|
||||
const columnConfig: ColumnConfig<TitleCredit>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: () => <Trans message="Person" />,
|
||||
visibleInMode: 'all',
|
||||
body: credit => (
|
||||
<div className="flex items-center gap-12">
|
||||
<PersonPoster rounded person={credit} size="w-44" />
|
||||
<div className="overflow-hidden min-w-0">{credit.name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
header: () => <Trans message="Department" />,
|
||||
body: credit => credit.pivot.department,
|
||||
},
|
||||
{
|
||||
key: 'job',
|
||||
header: () => <Trans message="Job" />,
|
||||
body: credit => credit.pivot.job,
|
||||
},
|
||||
getCreditsEditorActionColumn(),
|
||||
];
|
||||
|
||||
interface Props {
|
||||
query: UseInfiniteDataResult<TitleCredit>;
|
||||
}
|
||||
export function CrewEditorTable({query}: Props) {
|
||||
return (
|
||||
<Fragment>
|
||||
<Table
|
||||
enableSelection={false}
|
||||
columns={columnConfig}
|
||||
data={query.items}
|
||||
cellHeight="h-54"
|
||||
tableBody={<CreditsTableBody />}
|
||||
/>
|
||||
<CreditsTableQueryIndicator query={query} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function CreditsTableBody({renderRowAs}: TableBodyProps) {
|
||||
const {data} = useContext(TableContext);
|
||||
return (
|
||||
<Fragment>
|
||||
{data.map((item, rowIndex) => (
|
||||
<TableRow
|
||||
item={item}
|
||||
index={rowIndex}
|
||||
// use pivot id for key because some person might
|
||||
// appear multiple times with different department
|
||||
key={(item as TitleCredit).pivot.id}
|
||||
renderAs={renderRowAs}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {TitleCredit} from '@app/titles/models/title';
|
||||
import {
|
||||
UpdateTitleCreditPayload,
|
||||
useUpdateTitleCredit,
|
||||
} from '@app/admin/titles/requests/use-update-title-credit';
|
||||
import {SharedCreditDialogFields} from '@app/admin/titles/title-editor/credits-editor/add-credit-dialog';
|
||||
|
||||
interface Props {
|
||||
credit: TitleCredit;
|
||||
}
|
||||
export function EditCreditDialog({credit}: Props) {
|
||||
const {formId, close} = useDialogContext();
|
||||
const isCrew = credit.pivot.department !== 'actors';
|
||||
const form = useForm<UpdateTitleCreditPayload>({
|
||||
defaultValues: {
|
||||
character: credit.pivot.character,
|
||||
department: credit.pivot.department,
|
||||
job: credit.pivot.job,
|
||||
},
|
||||
});
|
||||
const updateCredit = useUpdateTitleCredit(form, credit.pivot.id);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Edit credit" />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Form
|
||||
id={formId}
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
updateCredit.mutate(values, {onSuccess: () => close()});
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
value={credit.name}
|
||||
label={<Trans message="Person" />}
|
||||
required
|
||||
readOnly
|
||||
disabled
|
||||
className="mb-24"
|
||||
/>
|
||||
<SharedCreditDialogFields isCrew={isCrew} />
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => close()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={updateCredit.isPending}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {EditCreditDialog} from '@app/admin/titles/title-editor/credits-editor/edit-credit-dialog';
|
||||
import React from 'react';
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {TitleCredit} from '@app/titles/models/title';
|
||||
import {useDeleteTitleCredit} from '@app/admin/titles/requests/use-delete-title-credit';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
|
||||
export const getCreditsEditorActionColumn = (): ColumnConfig<TitleCredit> => {
|
||||
return {
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-84 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: item => (
|
||||
<div className="text-muted">
|
||||
<DialogTrigger type="modal">
|
||||
<IconButton>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<EditCreditDialog credit={item} />
|
||||
</DialogTrigger>
|
||||
<DeleteButton creditId={item.pivot.id} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
interface DeleteButtonProps {
|
||||
creditId: number;
|
||||
}
|
||||
function DeleteButton({creditId}: DeleteButtonProps) {
|
||||
const deleteCredit = useDeleteTitleCredit(creditId);
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={confirmed => {
|
||||
if (confirmed) {
|
||||
deleteCredit.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconButton>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete credit" />}
|
||||
body={<Trans message="Are you sure you want to delete this credit?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {CastEditorTable} from '@app/admin/titles/title-editor/credits-editor/cast-editor-table';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {useTitleCredits} from '@app/admin/titles/requests/use-title-credits';
|
||||
import {TitleCreditsTableHeader} from '@app/admin/titles/title-editor/credits-editor/title-credits-table-header';
|
||||
|
||||
export function TitleCastEditor() {
|
||||
const query = useTitleCredits({
|
||||
department: 'actors',
|
||||
});
|
||||
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<TitleCreditsTableHeader query={query} isCrew={false} />
|
||||
<CastEditorTable query={query} />
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {AddCreditDialog} from '@app/admin/titles/title-editor/credits-editor/add-credit-dialog';
|
||||
import {TextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {UseInfiniteDataResult} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {TitleCredit} from '@app/titles/models/title';
|
||||
import {SearchIcon} from '@common/icons/material/Search';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
interface Props {
|
||||
query: UseInfiniteDataResult<TitleCredit>;
|
||||
isCrew: boolean;
|
||||
}
|
||||
export function TitleCreditsTableHeader({query, isCrew}: Props) {
|
||||
const {trans} = useTrans();
|
||||
return (
|
||||
<div className="flex items-center gap-24 justify-between mb-14">
|
||||
<DialogTrigger type="modal">
|
||||
<Button variant="outline" color="primary" startIcon={<AddIcon />}>
|
||||
<Trans message="Add credit" />
|
||||
</Button>
|
||||
<AddCreditDialog isCrew={isCrew} />
|
||||
</DialogTrigger>
|
||||
<TextField
|
||||
size="sm"
|
||||
value={query.searchQuery}
|
||||
onChange={e => query.setSearchQuery(e.target.value)}
|
||||
placeholder={trans(message('Search'))}
|
||||
startAdornment={<SearchIcon />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {useTitleCredits} from '@app/admin/titles/requests/use-title-credits';
|
||||
import {CrewEditorTable} from '@app/admin/titles/title-editor/credits-editor/crew-editor-table';
|
||||
import {TitleCreditsTableHeader} from '@app/admin/titles/title-editor/credits-editor/title-credits-table-header';
|
||||
|
||||
export function TitleCrewEditor() {
|
||||
const query = useTitleCredits({
|
||||
crewOnly: 'true',
|
||||
});
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<TitleCreditsTableHeader query={query} isCrew />
|
||||
<CrewEditorTable query={query} />
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
14
resources/client/admin/titles/title-editor/edit-title-page.tsx
Executable file
14
resources/client/admin/titles/title-editor/edit-title-page.tsx
Executable file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
|
||||
import {Outlet} from 'react-router-dom';
|
||||
import {useTitle} from '@app/titles/requests/use-title';
|
||||
|
||||
export function EditTitlePage() {
|
||||
const query = useTitle('editTitlePage');
|
||||
|
||||
if (!query.data) {
|
||||
return <FullPageLoader />;
|
||||
}
|
||||
|
||||
return <Outlet context={query.data.title} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import {EpisodeEditorLayout} from '@app/admin/titles/title-editor/episode-editor/episode-editor-layout';
|
||||
import {CastEditorTable} from '@app/admin/titles/title-editor/credits-editor/cast-editor-table';
|
||||
import {useTitleCredits} from '@app/admin/titles/requests/use-title-credits';
|
||||
import {TitleCreditsTableHeader} from '@app/admin/titles/title-editor/credits-editor/title-credits-table-header';
|
||||
|
||||
export function EpisodeCastEditor() {
|
||||
const query = useTitleCredits({
|
||||
department: 'actors',
|
||||
});
|
||||
|
||||
return (
|
||||
<EpisodeEditorLayout>
|
||||
<TitleCreditsTableHeader query={query} isCrew={false} />
|
||||
<CastEditorTable query={query} />
|
||||
</EpisodeEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import {EpisodeEditorLayout} from '@app/admin/titles/title-editor/episode-editor/episode-editor-layout';
|
||||
import {useTitleCredits} from '@app/admin/titles/requests/use-title-credits';
|
||||
import {TitleCreditsTableHeader} from '@app/admin/titles/title-editor/credits-editor/title-credits-table-header';
|
||||
import {CrewEditorTable} from '@app/admin/titles/title-editor/credits-editor/crew-editor-table';
|
||||
|
||||
export function EpisodeCrewEditor() {
|
||||
const query = useTitleCredits({
|
||||
crewOnly: 'true',
|
||||
});
|
||||
|
||||
return (
|
||||
<EpisodeEditorLayout>
|
||||
<TitleCreditsTableHeader query={query} isCrew />
|
||||
<CrewEditorTable query={query} />
|
||||
</EpisodeEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import React, {ReactNode} from 'react';
|
||||
import {Breadcrumb} from '@common/ui/breadcrumbs/breadcrumb';
|
||||
import {BreadcrumbItem} from '@common/ui/breadcrumbs/breadcrumb-item';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {Tabs} from '@common/ui/tabs/tabs';
|
||||
import {TabList} from '@common/ui/tabs/tab-list';
|
||||
import {Tab} from '@common/ui/tabs/tab';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {Link, useLocation, useParams} from 'react-router-dom';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
const PageTabs = [
|
||||
{uri: 'primary-facts', label: message('Primary facts')},
|
||||
{uri: 'cast', label: message('Cast')},
|
||||
{uri: 'crew', label: message('Crew')},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
export function EpisodeEditorLayout({children, actions}: Props) {
|
||||
const {episode, season} = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {pathname} = useLocation();
|
||||
const tabName = pathname.split('/').pop();
|
||||
|
||||
// only "primary facts" tab will be enabled when creating new episode
|
||||
const selectedTab = episode
|
||||
? PageTabs.findIndex(tab => tab.uri === tabName)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TitleEditorLayout actions={actions}>
|
||||
<Breadcrumb className="mb-24">
|
||||
<BreadcrumbItem
|
||||
onSelected={() => navigate('../..', {relative: 'path'})}
|
||||
>
|
||||
<Trans message="Season :number" values={{number: season}} />
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
{episode ? (
|
||||
<Trans message="Episode :number" values={{number: episode}} />
|
||||
) : (
|
||||
<Trans message="New episode" />
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<FileUploadProvider>
|
||||
<Tabs selectedTab={selectedTab}>
|
||||
<TabList>
|
||||
{PageTabs.map(tab => (
|
||||
<Tab
|
||||
isDisabled={!episode && tab.uri !== 'primary-facts'}
|
||||
key={tab.uri}
|
||||
width="min-w-132"
|
||||
elementType={Link}
|
||||
to={`../${tab.uri}`}
|
||||
relative="path"
|
||||
replace
|
||||
>
|
||||
<Trans {...tab.label} />
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<div className="pt-24">{children}</div>
|
||||
</Tabs>
|
||||
</FileUploadProvider>
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {FormDatePicker} from '@common/ui/forms/input-field/date/date-picker/date-picker';
|
||||
import React, {Fragment} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {useOutletContext, useParams} from 'react-router-dom';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {useUpdateEpisode} from '@app/episodes/requests/use-update-episode';
|
||||
import {EpisodeEditorLayout} from '@app/admin/titles/title-editor/episode-editor/episode-editor-layout';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {
|
||||
CreateEpisodePayload,
|
||||
useCreateEpisode,
|
||||
} from '@app/episodes/requests/use-create-episode';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {
|
||||
GetEpisodeResponse,
|
||||
useEpisode,
|
||||
} from '@app/episodes/requests/use-episode';
|
||||
import {TitleEditorPageStatus} from '@app/admin/titles/title-editor/title-editor-page-status';
|
||||
|
||||
export function EpisodePrimaryFactsForm() {
|
||||
const {episode: episodeNumber} = useParams();
|
||||
if (episodeNumber) {
|
||||
return <UpdateEpisodePanel />;
|
||||
} else {
|
||||
return <NewEpisodeForm />;
|
||||
}
|
||||
}
|
||||
|
||||
function NewEpisodeForm() {
|
||||
const title = useOutletContext<Title>();
|
||||
const {season} = useParams();
|
||||
const navigate = useNavigate();
|
||||
const now = useCurrentDateTime();
|
||||
const form = useForm<CreateEpisodePayload>({
|
||||
defaultValues: {
|
||||
release_date: now.toAbsoluteString(),
|
||||
},
|
||||
});
|
||||
const createEpisode = useCreateEpisode(form, title.id, season!);
|
||||
const isDirty = Object.keys(form.formState.dirtyFields).length > 0;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
createEpisode.mutate(values, {
|
||||
onSuccess: response => {
|
||||
toast(message('Episode created'));
|
||||
navigate(`../${response.episode.episode_number}`, {
|
||||
relative: 'path',
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EpisodeEditorLayout
|
||||
actions={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={createEpisode.isPending || !isDirty}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FormFields />
|
||||
</EpisodeEditorLayout>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateEpisodePanel() {
|
||||
const query = useEpisode('episode');
|
||||
return query.data ? (
|
||||
<UpdateEpisodeForm episode={query.data.episode} />
|
||||
) : (
|
||||
<EpisodeEditorLayout
|
||||
actions={
|
||||
<Button variant="flat" color="primary" type="submit" disabled>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TitleEditorPageStatus query={query} />
|
||||
</EpisodeEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface UpdateEpisodeFormProps {
|
||||
episode: GetEpisodeResponse['episode'];
|
||||
}
|
||||
function UpdateEpisodeForm({episode}: UpdateEpisodeFormProps) {
|
||||
const title = useOutletContext<Title>();
|
||||
const navigate = useNavigate();
|
||||
const form = useForm<CreateEpisodePayload>({
|
||||
defaultValues: {
|
||||
name: episode.name,
|
||||
description: episode.description,
|
||||
release_date: episode.release_date,
|
||||
runtime: episode.runtime,
|
||||
popularity: episode.popularity,
|
||||
poster: episode.poster,
|
||||
},
|
||||
});
|
||||
|
||||
const updateEpisode = useUpdateEpisode(
|
||||
title.id,
|
||||
episode.season_number,
|
||||
episode.episode_number,
|
||||
form,
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
updateEpisode.mutate(values, {
|
||||
onSuccess: () => {
|
||||
toast(message('Episode updated'));
|
||||
navigate('../../../', {relative: 'path'});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<EpisodeEditorLayout
|
||||
actions={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={updateEpisode.isPending || !form.formState.isDirty}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FormFields />
|
||||
</EpisodeEditorLayout>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFields() {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="gap-24 md:flex">
|
||||
<FormImageSelector
|
||||
variant="square"
|
||||
previewSize="w-204 aspect-poster"
|
||||
name="poster"
|
||||
diskPrefix="episode-posters"
|
||||
label={<Trans message="Poster" />}
|
||||
stretchPreview
|
||||
/>
|
||||
<div className="mb-24 flex-auto max-md:mt-24">
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Title" />}
|
||||
className="mb-24"
|
||||
required
|
||||
/>
|
||||
<FormDatePicker
|
||||
name="release_date"
|
||||
label={<Trans message="Release date" />}
|
||||
className="mb-24"
|
||||
granularity="day"
|
||||
/>
|
||||
<FormTextField
|
||||
name="runtime"
|
||||
label={<Trans message="Runtime" />}
|
||||
type="number"
|
||||
min={1}
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormTextField
|
||||
name="popularity"
|
||||
label={<Trans message="Popularity" />}
|
||||
type="number"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormTextField
|
||||
name="description"
|
||||
label={<Trans message="Overview" />}
|
||||
inputElementType="textarea"
|
||||
rows={6}
|
||||
className="mb-24"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {CastEditorTable} from '@app/admin/titles/title-editor/credits-editor/cast-editor-table';
|
||||
import {useTitleCredits} from '@app/admin/titles/requests/use-title-credits';
|
||||
import {SeasonEditorLayout} from '@app/admin/titles/title-editor/seasons-editor/season-editor-layout';
|
||||
import {TitleCreditsTableHeader} from '@app/admin/titles/title-editor/credits-editor/title-credits-table-header';
|
||||
|
||||
export function SeasonCastEditor() {
|
||||
const query = useTitleCredits({
|
||||
department: 'actors',
|
||||
});
|
||||
return (
|
||||
<SeasonEditorLayout>
|
||||
<TitleCreditsTableHeader query={query} isCrew={false} />
|
||||
<CastEditorTable query={query} />
|
||||
</SeasonEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {useTitleCredits} from '@app/admin/titles/requests/use-title-credits';
|
||||
import {SeasonEditorLayout} from '@app/admin/titles/title-editor/seasons-editor/season-editor-layout';
|
||||
import {TitleCreditsTableHeader} from '@app/admin/titles/title-editor/credits-editor/title-credits-table-header';
|
||||
import {CrewEditorTable} from '@app/admin/titles/title-editor/credits-editor/crew-editor-table';
|
||||
|
||||
export function SeasonCrewEditor() {
|
||||
const query = useTitleCredits({
|
||||
crewOnly: 'true',
|
||||
});
|
||||
return (
|
||||
<SeasonEditorLayout>
|
||||
<TitleCreditsTableHeader query={query} isCrew />
|
||||
<CrewEditorTable query={query} />
|
||||
</SeasonEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import {GetSeasonResponse, useSeason} from '@app/seasons/requests/use-season';
|
||||
import React, {Fragment} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteEpisode} from '@app/episodes/requests/use-delete-episode';
|
||||
import {Episode} from '@app/titles/models/episode';
|
||||
import {EpisodeListItem} from '@app/seasons/episode-list-item';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {SeasonEditorLayout} from '@app/admin/titles/title-editor/seasons-editor/season-editor-layout';
|
||||
import {useSeasonEpisodes} from '@app/titles/requests/use-season-episodes';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import {TitleEditorPageStatus} from '@app/admin/titles/title-editor/title-editor-page-status';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {TvIcon} from '@common/icons/material/Tv';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
|
||||
export function SeasonEditorEpisodeList() {
|
||||
return (
|
||||
<SeasonEditorLayout>
|
||||
<div className="mb-16">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
size="xs"
|
||||
elementType={Link}
|
||||
to="new"
|
||||
>
|
||||
<Trans message="Add episode" />
|
||||
</Button>
|
||||
</div>
|
||||
<Content />
|
||||
</SeasonEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Content() {
|
||||
const query = useSeason('editSeasonPage');
|
||||
if (query.data) {
|
||||
return query.data.episodes?.data.length ? (
|
||||
<LazyEpisodeList data={query.data} />
|
||||
) : (
|
||||
<NoEpisodesMessage />
|
||||
);
|
||||
} else {
|
||||
return <TitleEditorPageStatus query={query} />;
|
||||
}
|
||||
}
|
||||
|
||||
function NoEpisodesMessage() {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
imageMargin="mb-8"
|
||||
image={
|
||||
<div className="text-muted">
|
||||
<TvIcon size="xl" />
|
||||
</div>
|
||||
}
|
||||
imageHeight="h-auto"
|
||||
title={<Trans message="No episodes have been added yet" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LazyEpisodeListProps {
|
||||
data: GetSeasonResponse;
|
||||
}
|
||||
function LazyEpisodeList({data}: LazyEpisodeListProps) {
|
||||
const query = useSeasonEpisodes(data.episodes);
|
||||
return (
|
||||
<Fragment>
|
||||
{query.items.map(episode => (
|
||||
<EpisodeListItem
|
||||
key={episode.id}
|
||||
episode={episode}
|
||||
title={data.title}
|
||||
className="mb-24"
|
||||
>
|
||||
<div className="mt-12 flex items-center gap-12">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
startIcon={<EditIcon />}
|
||||
elementType={Link}
|
||||
to={`${episode.episode_number}/primary-facts`}
|
||||
>
|
||||
<Trans message="Edit" />
|
||||
</Button>
|
||||
<DialogTrigger type="modal">
|
||||
<IconButton size="xs" variant="outline">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<DeleteEpisodeDialog episode={episode} />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</EpisodeListItem>
|
||||
))}
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteEpisodeDialogProps {
|
||||
episode: Episode;
|
||||
}
|
||||
function DeleteEpisodeDialog({episode}: DeleteEpisodeDialogProps) {
|
||||
const deleteEpisode = useDeleteEpisode(episode);
|
||||
const {close} = useDialogContext();
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
isLoading={deleteEpisode.isPending}
|
||||
isDanger
|
||||
title={<Trans message="Delete episode" />}
|
||||
body={<Trans message="Are you sure you want to delete this episode?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
onConfirm={() => {
|
||||
deleteEpisode.mutate(undefined, {onSuccess: () => close()});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, {ReactNode} from 'react';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link, useLocation, useParams} from 'react-router-dom';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {TabList} from '@common/ui/tabs/tab-list';
|
||||
import {Tab} from '@common/ui/tabs/tab';
|
||||
import {Tabs} from '@common/ui/tabs/tabs';
|
||||
import {message} from '@common/i18n/message';
|
||||
|
||||
const PageTabs = [
|
||||
{uri: 'episodes', label: message('Episodes')},
|
||||
{uri: 'cast', label: message('Regular cast')},
|
||||
{uri: 'crew', label: message('Regular crew')},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
export function SeasonEditorLayout({children}: Props) {
|
||||
const {season: seasonNumber} = useParams();
|
||||
|
||||
const {pathname} = useLocation();
|
||||
const tabName = pathname.split('/').pop();
|
||||
|
||||
// only "episodes" tab will be enabled when creating new episode
|
||||
const selectedTab = seasonNumber
|
||||
? PageTabs.findIndex(tab => tab.uri === tabName)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<div className="flex items-center gap-12 mb-4">
|
||||
<IconButton
|
||||
elementType={Link}
|
||||
to="../../"
|
||||
relative="path"
|
||||
className="text-muted"
|
||||
>
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<h2 className="text-base">
|
||||
<Trans message="Season :number" values={{number: seasonNumber}} />
|
||||
</h2>
|
||||
</div>
|
||||
<Tabs selectedTab={selectedTab}>
|
||||
<TabList>
|
||||
{PageTabs.map(tab => (
|
||||
<Tab
|
||||
isDisabled={!seasonNumber && tab.uri !== PageTabs[0].uri}
|
||||
key={tab.uri}
|
||||
width="min-w-132"
|
||||
elementType={Link}
|
||||
to={`../${tab.uri}`}
|
||||
relative="path"
|
||||
replace
|
||||
>
|
||||
<Trans {...tab.label} />
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<div className="pt-24 min-h-512">{children}</div>
|
||||
</Tabs>
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Season} from '@app/titles/models/season';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Link, useOutletContext} from 'react-router-dom';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import React, {Fragment} from 'react';
|
||||
import {useDeleteSeason} from '@app/admin/titles/requests/use-delete-season';
|
||||
import {useTitleSeasons} from '@app/titles/requests/use-title-seasons';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {TitleEditorPageStatus} from '@app/admin/titles/title-editor/title-editor-page-status';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {SeasonPoster} from '@app/seasons/season-poster';
|
||||
import {SeasonLink} from '@app/seasons/season-link';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {TvIcon} from '@common/icons/material/Tv';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {useCreateSeason} from '@app/admin/titles/requests/use-create-season';
|
||||
|
||||
export function TitleSeasonsEditor() {
|
||||
const title = useOutletContext<Title>();
|
||||
const createSeason = useCreateSeason(title.id);
|
||||
const query = useTitleSeasons(title.id, undefined, {
|
||||
perPage: 15,
|
||||
});
|
||||
|
||||
let content;
|
||||
if (query.data) {
|
||||
content = query.items.length ? (
|
||||
<Fragment>
|
||||
<div className="mt-24 grid grid-cols-2 gap-24 md:grid-cols-5">
|
||||
{query.items.map(season => (
|
||||
<div key={season.id}>
|
||||
<SeasonPoster
|
||||
title={title}
|
||||
season={season}
|
||||
srcSize="md"
|
||||
className="aspect-poster flex-shrink-0"
|
||||
/>
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between gap-14">
|
||||
<SeasonLink title={title} seasonNumber={season.number} />
|
||||
<div className="text-xs text-muted">
|
||||
<FormattedDate
|
||||
date={season.release_date}
|
||||
options={{year: 'numeric'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm">
|
||||
{
|
||||
<Trans
|
||||
message=":count episodes"
|
||||
values={{count: season.episodes_count}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div className="mt-14 flex items-center justify-between gap-14">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
startIcon={<EditIcon />}
|
||||
elementType={Link}
|
||||
to={`${season.number}/episodes`}
|
||||
>
|
||||
<Trans message="Edit" />
|
||||
</Button>
|
||||
<DeleteButton title={title} season={season} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<NoSeasonsMessage />
|
||||
);
|
||||
} else {
|
||||
content = <TitleEditorPageStatus query={query} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
disabled={createSeason.isPending}
|
||||
onClick={() => createSeason.mutate()}
|
||||
>
|
||||
<Trans message="Add season" />
|
||||
</Button>
|
||||
{content}
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function NoSeasonsMessage() {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
imageMargin="mb-8"
|
||||
image={
|
||||
<div className="text-muted">
|
||||
<TvIcon size="xl" />
|
||||
</div>
|
||||
}
|
||||
imageHeight="h-auto"
|
||||
title={<Trans message="No seasons have been added yet" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteButtonProps {
|
||||
title: Title;
|
||||
season: Season;
|
||||
}
|
||||
function DeleteButton({title, season}: DeleteButtonProps) {
|
||||
const deleteSeason = useDeleteSeason(title, season.id);
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={confirmed => {
|
||||
if (confirmed) {
|
||||
deleteSeason.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconButton size="xs" variant="outline">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete season" />}
|
||||
body={<Trans message="Are you sure you want to delete this season?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
13
resources/client/admin/titles/title-editor/title-comments-editor.tsx
Executable file
13
resources/client/admin/titles/title-editor/title-comments-editor.tsx
Executable file
@@ -0,0 +1,13 @@
|
||||
import {useOutletContext} from 'react-router-dom';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {CommentsDatatablePage} from '@common/comments/comments-datatable-page/comments-datatable-page';
|
||||
|
||||
export function TitleCommentsEditor() {
|
||||
const title = useOutletContext<Title>();
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<CommentsDatatablePage hideTitle commentable={title} />
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
168
resources/client/admin/titles/title-editor/title-editor-layout.tsx
Executable file
168
resources/client/admin/titles/title-editor/title-editor-layout.tsx
Executable file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
Link,
|
||||
NavLink,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {StaticPageTitle} from '@common/seo/static-page-title';
|
||||
import {Option, Select} from '@common/ui/forms/select/select';
|
||||
import React, {Fragment, ReactNode, useRef} from 'react';
|
||||
import {useStickySentinel} from '@common/utils/hooks/sticky-sentinel';
|
||||
import {getTitleLink} from '@app/titles/title-link';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {OpenInNewIcon} from '@common/icons/material/OpenInNew';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {useScrollToTop} from '@common/ui/navigation/use-scroll-to-top';
|
||||
import {InfoDialogTriggerIcon} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger-icon';
|
||||
|
||||
const allMenuItems = [
|
||||
{to: 'primary-facts', label: message('Primary Facts')},
|
||||
{to: 'seasons', label: message('Seasons'), hideIfMovie: true},
|
||||
{to: 'images', label: message('Images')},
|
||||
{to: 'videos', label: message('Videos')},
|
||||
{to: 'cast', label: message('Cast')},
|
||||
{to: 'crew', label: message('Crew')},
|
||||
{to: 'genres', label: message('Genres')},
|
||||
{to: 'keywords', label: message('Keywords')},
|
||||
{to: 'countries', label: message('Countries')},
|
||||
{to: 'reviews', label: message('Reviews')},
|
||||
{to: 'comments', label: message('Comments')},
|
||||
];
|
||||
function useFilteredMenuItems() {
|
||||
const title = useOutletContext<Title>();
|
||||
const isMovie = !title?.is_series;
|
||||
|
||||
return allMenuItems.filter(item => !isMovie || !item.hideIfMovie);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
export function TitleEditorLayout({children, actions}: Props) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const {isSticky, sentinelRef} = useStickySentinel();
|
||||
const title = useOutletContext<Title>();
|
||||
const {season, episode} = useParams();
|
||||
const link = title ? getTitleLink(title, {season, episode}) : null;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const heading = title ? (
|
||||
<Trans values={{name: title.name}} message="Edit “:name“" />
|
||||
) : (
|
||||
<Trans message="New title" />
|
||||
);
|
||||
|
||||
useScrollToTop(ref);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<StaticPageTitle>
|
||||
<Trans message="Edit title" />
|
||||
</StaticPageTitle>
|
||||
<div ref={sentinelRef} />
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'sticky top-0 my-12 md:my-24 z-10 transition-shadow',
|
||||
isSticky && 'bg-paper shadow'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center md:items-start gap-24 py-14 container mx-auto px-24'
|
||||
)}
|
||||
>
|
||||
<h1 className="text-xl md:text-3xl whitespace-nowrap overflow-hidden overflow-ellipsis md:mr-64">
|
||||
{heading}
|
||||
</h1>
|
||||
<div className="mr-auto"></div>
|
||||
{link ? (
|
||||
<IconButton size="sm" elementType={Link} to={link} target="_blank">
|
||||
<OpenInNewIcon />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
<div className="container md:flex gap-30 items-stretch mx-auto px-24 pb-24">
|
||||
{isMobile ? <MobileNav /> : <DesktopNav />}
|
||||
<div className="md:pl-30 flex-auto relative">{children}</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNav() {
|
||||
const {titleId} = useParams();
|
||||
const {pathname} = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const value = titleId ? pathname.split('/').pop() : 'primary-facts';
|
||||
const menuItems = useFilteredMenuItems();
|
||||
|
||||
return (
|
||||
<Select
|
||||
disabled={!titleId}
|
||||
minWidth="min-w-none"
|
||||
className="w-full bg-paper mb-24"
|
||||
selectionMode="single"
|
||||
selectedValue={value}
|
||||
onSelectionChange={newPage => {
|
||||
if (titleId) {
|
||||
navigate(itemLink(titleId, newPage as string));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{menuItems.map(item => (
|
||||
<Option key={item.to} value={item.to}>
|
||||
<Trans {...item.label} />
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopNav() {
|
||||
const {titleId} = useParams();
|
||||
const menuItems = useFilteredMenuItems();
|
||||
return (
|
||||
<div className="w-240 sticky top-24 flex-shrink-0">
|
||||
{menuItems.map(item => {
|
||||
const link = titleId ? itemLink(titleId, item.to) : '';
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={link}
|
||||
aria-disabled={!titleId}
|
||||
className={({isActive}) =>
|
||||
clsx(
|
||||
'block p-14 whitespace-nowrap mb-8 rounded border-l-4 text-sm transition-bg-color',
|
||||
!link && 'pointer-events-none text-muted',
|
||||
(isActive && link) || (item.to === 'primary-facts' && !link)
|
||||
? 'bg-primary/selected border-l-primary font-medium'
|
||||
: 'border-l-transparent hover:bg-hover'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trans {...item.label} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{!titleId ? (
|
||||
<div className="flex items-center gap-8 text-muted text-xs mt-24">
|
||||
<InfoDialogTriggerIcon viewBox="0 0 16 16" size="xs" />
|
||||
<Trans message="Create title to enable menu items." />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const itemLink = (titleId: string | number, to: string) =>
|
||||
`/admin/titles/${titleId}/edit/${to}`;
|
||||
19
resources/client/admin/titles/title-editor/title-editor-page-status.tsx
Executable file
19
resources/client/admin/titles/title-editor/title-editor-page-status.tsx
Executable file
@@ -0,0 +1,19 @@
|
||||
import {PageErrorMessage} from '@common/errors/page-error-message';
|
||||
import React from 'react';
|
||||
import {UseQueryResult} from '@tanstack/react-query';
|
||||
import {ProgressCircle} from '@common/ui/progress/progress-circle';
|
||||
|
||||
interface Props {
|
||||
query: UseQueryResult;
|
||||
}
|
||||
export function TitleEditorPageStatus({query}: Props) {
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="h-full min-h-120 flex items-center justify-center">
|
||||
<ProgressCircle isIndeterminate aria-label="Loading page..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PageErrorMessage />;
|
||||
}
|
||||
121
resources/client/admin/titles/title-editor/title-images-editor.tsx
Executable file
121
resources/client/admin/titles/title-editor/title-images-editor.tsx
Executable file
@@ -0,0 +1,121 @@
|
||||
import {TitleBackdrop} from '@app/titles/title-poster/title-backdrop';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ImageZoomDialog} from '@common/ui/overlays/dialog/image-zoom-dialog';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ZoomOutMapIcon} from '@common/icons/material/ZoomOutMap';
|
||||
import {useDeleteImage} from '@app/admin/titles/requests/use-delete-image';
|
||||
import {UploadInputType} from '@common/uploads/types/upload-input-config';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {openUploadWindow} from '@common/uploads/utils/open-upload-window';
|
||||
import {useUploadImage} from '@app/admin/titles/requests/use-upload-image';
|
||||
import {useOutletContext, useParams} from 'react-router-dom';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {validateUpload} from '@common/uploads/uploader/validate-upload';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import React from 'react';
|
||||
import {ImageIcon} from '@common/icons/material/Image';
|
||||
|
||||
export function TitleImagesEditor() {
|
||||
const title = useOutletContext<Title>();
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<FileUploadProvider>
|
||||
<UploadButton />
|
||||
</FileUploadProvider>
|
||||
<div className="mt-24 grid grid-cols-2 gap-24 md:grid-cols-3">
|
||||
{title.images.map((image, index) => (
|
||||
<div key={image.id}>
|
||||
<TitleBackdrop src={image.url} srcSize="md" className="rounded" />
|
||||
<div className="mt-6 flex items-center justify-between gap-14">
|
||||
<DeleteButton imageId={image.id} />
|
||||
<DialogTrigger type="modal">
|
||||
<IconButton variant="outline" size="xs">
|
||||
<ZoomOutMapIcon />
|
||||
</IconButton>
|
||||
<ImageZoomDialog
|
||||
images={title.images.map(img => img.url)}
|
||||
defaultActiveIndex={index}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!title.images.length && <NoImagesMessage />}
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function NoImagesMessage() {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
imageMargin="mb-8"
|
||||
image={
|
||||
<div className="text-muted">
|
||||
<ImageIcon size="xl" />
|
||||
</div>
|
||||
}
|
||||
imageHeight="h-auto"
|
||||
title={<Trans message="No images have been added yet" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE = 5000000;
|
||||
function UploadButton() {
|
||||
const {titleId} = useParams();
|
||||
const uploadImage = useUploadImage();
|
||||
|
||||
const selectAndUploadFile = async () => {
|
||||
const files = await openUploadWindow({
|
||||
types: [UploadInputType.image],
|
||||
});
|
||||
const errorMessage = validateUpload(files[0], {
|
||||
maxFileSize: MAX_IMAGE_SIZE,
|
||||
});
|
||||
if (errorMessage) {
|
||||
toast.danger(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
uploadImage.mutate({
|
||||
file: files[0].native,
|
||||
titleId: titleId!,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
disabled={uploadImage.isPending}
|
||||
onClick={() => selectAndUploadFile()}
|
||||
>
|
||||
<Trans message="Upload image" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageItemProps {
|
||||
imageId: number;
|
||||
}
|
||||
function DeleteButton({imageId}: ImageItemProps) {
|
||||
const deleteImage = useDeleteImage(imageId);
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={deleteImage.isPending}
|
||||
onClick={() => deleteImage.mutate()}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
271
resources/client/admin/titles/title-editor/title-primary-facts-form.tsx
Executable file
271
resources/client/admin/titles/title-editor/title-primary-facts-form.tsx
Executable file
@@ -0,0 +1,271 @@
|
||||
import {FormImageSelector} from '@common/ui/images/image-selector';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
|
||||
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
|
||||
import {FormSwitch} from '@common/ui/forms/toggle/switch';
|
||||
import {FormDatePicker} from '@common/ui/forms/input-field/date/date-picker/date-picker';
|
||||
import {FormSelect, Option} from '@common/ui/forms/select/select';
|
||||
import {useValueLists} from '@common/http/value-lists';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {
|
||||
CreateTitlePayload,
|
||||
useCreateTitle,
|
||||
} from '@app/admin/titles/requests/use-create-title';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {useUpdateTitle} from '@app/admin/titles/requests/use-update-title';
|
||||
import {toast} from '@common/ui/toast/toast';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {useOutletContext} from 'react-router-dom';
|
||||
import React, {Fragment} from 'react';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {useCurrentDateTime} from '@common/i18n/use-current-date-time';
|
||||
import {FormComboBox} from '@common/ui/forms/combobox/form-combobox';
|
||||
|
||||
export function TitlePrimaryFactsForm() {
|
||||
const title = useOutletContext<Title>();
|
||||
|
||||
return (
|
||||
<FileUploadProvider>
|
||||
{title ? <EditTitleForm title={title} /> : <CreateTitleForm />}
|
||||
</FileUploadProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTitleForm() {
|
||||
const now = useCurrentDateTime();
|
||||
const navigate = useNavigate();
|
||||
const form = useForm<CreateTitlePayload>({
|
||||
defaultValues: {
|
||||
release_date: now.toAbsoluteString(),
|
||||
certification: 'pg',
|
||||
language: 'en',
|
||||
},
|
||||
});
|
||||
const createTitle = useCreateTitle(form);
|
||||
const isDirty = Object.keys(form.formState.dirtyFields).length > 0;
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
createTitle.mutate(values, {
|
||||
onSuccess: response => {
|
||||
toast(message('Title created'));
|
||||
navigate(`../${response.title.id}/edit`, {
|
||||
relative: 'path',
|
||||
replace: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TitleEditorLayout
|
||||
actions={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={createTitle.isPending || !isDirty}
|
||||
>
|
||||
<Trans message="Create" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FormFields />
|
||||
</TitleEditorLayout>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditTitleFormProps {
|
||||
title: Title;
|
||||
}
|
||||
function EditTitleForm({title}: EditTitleFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const form = useForm<CreateTitlePayload>({
|
||||
defaultValues: {
|
||||
name: title.name,
|
||||
is_series: title.is_series,
|
||||
original_title: title.original_title,
|
||||
poster: title.poster,
|
||||
backdrop: title.backdrop,
|
||||
release_date: title.release_date,
|
||||
tagline: title.tagline,
|
||||
description: title.description,
|
||||
runtime: title.runtime,
|
||||
certification: title.certification,
|
||||
budget: title.budget,
|
||||
revenue: title.revenue,
|
||||
language: title.language,
|
||||
popularity: title.popularity,
|
||||
},
|
||||
});
|
||||
const updateTitle = useUpdateTitle(form);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
updateTitle.mutate(values, {
|
||||
onSuccess: () => {
|
||||
toast(message('Title updated'));
|
||||
navigate('../../../', {relative: 'path', replace: true});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<TitleEditorLayout
|
||||
actions={
|
||||
<Button
|
||||
variant="flat"
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled={updateTitle.isPending || !form.formState.isDirty}
|
||||
>
|
||||
<Trans message="Save" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FormFields />
|
||||
</TitleEditorLayout>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function FormFields() {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="gap-24 md:flex">
|
||||
<FormImageSelector
|
||||
variant="square"
|
||||
previewSize="w-204 aspect-poster"
|
||||
name="poster"
|
||||
diskPrefix="title-posters"
|
||||
label={<Trans message="Poster" />}
|
||||
showRemoveButton
|
||||
/>
|
||||
<div className="flex-auto max-md:mt-24">
|
||||
<FormImageSelector
|
||||
name="backdrop"
|
||||
variant="square"
|
||||
diskPrefix="title-backdrops"
|
||||
label={<Trans message="Backdrop" />}
|
||||
stretchPreview
|
||||
previewSize="min-h-124"
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormTextField
|
||||
name="name"
|
||||
label={<Trans message="Title" />}
|
||||
className="mb-24"
|
||||
required
|
||||
/>
|
||||
<FormTextField
|
||||
name="original_title"
|
||||
label={<Trans message="Original title" />}
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormSwitch name="is_series" className="mb-24">
|
||||
<Trans message="Series" />
|
||||
</FormSwitch>
|
||||
</div>
|
||||
</div>
|
||||
<FormDatePicker
|
||||
name="release_date"
|
||||
label={<Trans message="Release date" />}
|
||||
className="mb-24"
|
||||
granularity="day"
|
||||
/>
|
||||
<FormTextField
|
||||
name="tagline"
|
||||
label={<Trans message="Tagline" />}
|
||||
className="mb-24"
|
||||
/>
|
||||
<FormTextField
|
||||
name="description"
|
||||
label={<Trans message="Overview" />}
|
||||
inputElementType="textarea"
|
||||
rows={4}
|
||||
className="mb-24"
|
||||
/>
|
||||
<div className="mb-24 items-center gap-24 md:flex">
|
||||
<FormTextField
|
||||
name="runtime"
|
||||
label={<Trans message="Runtime" />}
|
||||
type="number"
|
||||
min={1}
|
||||
className="flex-1 max-md:mb-24"
|
||||
/>
|
||||
<CertificationCombobox />
|
||||
</div>
|
||||
<div className="mb-24 items-center gap-24 md:flex">
|
||||
<FormTextField
|
||||
name="budget"
|
||||
label={<Trans message="Budget (US dollars)" />}
|
||||
type="number"
|
||||
min={1}
|
||||
className="flex-1 max-md:mb-24"
|
||||
/>
|
||||
<FormTextField
|
||||
name="revenue"
|
||||
label={<Trans message="Revenue (US dollars)" />}
|
||||
type="number"
|
||||
min={1}
|
||||
className="flex-1 max-md:mb-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-24 items-center gap-24 md:flex">
|
||||
<FormTextField
|
||||
name="popularity"
|
||||
label={<Trans message="Popularity" />}
|
||||
type="number"
|
||||
min={1}
|
||||
className="flex-1 max-md:mb-24"
|
||||
/>
|
||||
<LanguageSelect />
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificationCombobox() {
|
||||
const {data} = useValueLists(['titleFilterAgeRatings']);
|
||||
return (
|
||||
<FormComboBox
|
||||
name="certification"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Certification" />}
|
||||
className="flex-1"
|
||||
allowCustomValue
|
||||
>
|
||||
{data?.titleFilterAgeRatings.map(({name, value}) => (
|
||||
<Option key={value} value={value}>
|
||||
<Trans message={name} />
|
||||
</Option>
|
||||
))}
|
||||
</FormComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageSelect() {
|
||||
const {data} = useValueLists(['tmdbLanguages']);
|
||||
return (
|
||||
<FormSelect
|
||||
name="language"
|
||||
selectionMode="single"
|
||||
label={<Trans message="Language" />}
|
||||
showSearchField
|
||||
searchPlaceholder="Search languages"
|
||||
className="flex-1"
|
||||
>
|
||||
{data?.tmdbLanguages.map(language => (
|
||||
<Option key={language.code} value={language.code}>
|
||||
<Trans message={language.name} />
|
||||
</Option>
|
||||
))}
|
||||
</FormSelect>
|
||||
);
|
||||
}
|
||||
13
resources/client/admin/titles/title-editor/title-reviews-editor.tsx
Executable file
13
resources/client/admin/titles/title-editor/title-reviews-editor.tsx
Executable file
@@ -0,0 +1,13 @@
|
||||
import {ReviewsDatatablePage} from '@app/admin/reviews/reviews-datatable-page';
|
||||
import {useOutletContext} from 'react-router-dom';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
|
||||
export function TitleReviewsEditor() {
|
||||
const title = useOutletContext<Title>();
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<ReviewsDatatablePage hideTitle reviewable={title} />
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {Dialog} from '@common/ui/overlays/dialog/dialog';
|
||||
import {DialogHeader} from '@common/ui/overlays/dialog/dialog-header';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {DialogBody} from '@common/ui/overlays/dialog/dialog-body';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {Form} from '@common/ui/forms/form';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {TitleTag} from '@app/admin/titles/requests/use-detach-title-tag';
|
||||
import React, {useState} from 'react';
|
||||
import {Item} from '@common/ui/forms/listbox/item';
|
||||
import {FormComboBox} from '@common/ui/forms/combobox/form-combobox';
|
||||
import {useNormalizedModels} from '@common/users/queries/use-normalized-models';
|
||||
import {
|
||||
AttachTitleTagPayload,
|
||||
useAttachTitleTag,
|
||||
} from '@app/admin/titles/requests/use-attach-title-tag';
|
||||
|
||||
interface Props {
|
||||
type: TitleTag['model_type'];
|
||||
}
|
||||
export function AddTitleTagDialog({type}: Props) {
|
||||
const {formId, close} = useDialogContext();
|
||||
const form = useForm<AttachTitleTagPayload>();
|
||||
const attachTag = useAttachTitleTag(form, type);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogHeader>
|
||||
<Trans message="Add :name" values={{name: type.replace('_', ' ')}} />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<Form
|
||||
id={formId}
|
||||
form={form}
|
||||
onSubmit={values => {
|
||||
attachTag.mutate(values, {onSuccess: () => close()});
|
||||
}}
|
||||
>
|
||||
<NameField type={type} />
|
||||
</Form>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => close()}>
|
||||
<Trans message="Cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
variant="flat"
|
||||
color="primary"
|
||||
disabled={attachTag.isPending}
|
||||
>
|
||||
<Trans message="Add" />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface NameFieldProps {
|
||||
type: TitleTag['model_type'];
|
||||
}
|
||||
function NameField({type}: NameFieldProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const {isFetching, data} = useNormalizedModels(`normalized-models/${type}`, {
|
||||
query,
|
||||
});
|
||||
return (
|
||||
<FormComboBox
|
||||
isAsync
|
||||
name="tag_name"
|
||||
isLoading={isFetching}
|
||||
inputValue={query}
|
||||
onInputValueChange={setQuery}
|
||||
items={data?.results}
|
||||
allowCustomValue
|
||||
autoFocus
|
||||
>
|
||||
{item => (
|
||||
<Item key={item.id} value={item.name} textLabel={item.name}>
|
||||
<Trans message={item.description || item.name} />
|
||||
</Item>
|
||||
)}
|
||||
</FormComboBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {ColumnConfig} from '@common/datatable/column-config';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import React from 'react';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {IconButton} from '@common/ui/buttons/icon-button';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useOutletContext} from 'react-router-dom';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {Table} from '@common/ui/tables/table';
|
||||
import {
|
||||
TitleTag,
|
||||
useDetachTitleTag,
|
||||
} from '@app/admin/titles/requests/use-detach-title-tag';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {AddTitleTagDialog} from '@app/admin/titles/title-editor/title-tags-editor/add-title-tag-dialog';
|
||||
import {GENRE_MODEL} from '@app/titles/models/genre';
|
||||
import {KEYWORD_MODEL} from '@app/titles/models/keyword';
|
||||
import {PRODUCTION_COUNTRY_MODEL} from '@app/titles/models/production-country';
|
||||
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
|
||||
import {CloseIcon} from '@common/icons/material/Close';
|
||||
|
||||
const columnConfig: ColumnConfig<TitleTag>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: () => <Trans message="ID" />,
|
||||
visibleInMode: 'all',
|
||||
body: tag => <span>{tag.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'display_name',
|
||||
header: () => <Trans message="Display name" />,
|
||||
body: tag => <span>{tag.display_name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: () => <Trans message="Actions" />,
|
||||
hideHeader: true,
|
||||
align: 'end',
|
||||
width: 'w-42 flex-shrink-0',
|
||||
visibleInMode: 'all',
|
||||
body: tag => (
|
||||
<DialogTrigger type="modal">
|
||||
<IconButton className="text-muted">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<DetachTagDialog tag={tag} />
|
||||
</DialogTrigger>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
type: TitleTag['model_type'];
|
||||
}
|
||||
export function TitleTagsEditor({type}: Props) {
|
||||
const data = useTableData({type});
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<div className="mb-14">
|
||||
<DialogTrigger type="modal">
|
||||
<Button variant="outline" color="primary" startIcon={<AddIcon />}>
|
||||
<Trans
|
||||
message="Add :name"
|
||||
values={{name: type.replace('_', ' ')}}
|
||||
/>
|
||||
</Button>
|
||||
<AddTitleTagDialog type={type} />
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<Table
|
||||
enableSelection={false}
|
||||
columns={columnConfig}
|
||||
data={data as any}
|
||||
/>
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function useTableData({type}: Props) {
|
||||
const title = useOutletContext<Title>();
|
||||
switch (type) {
|
||||
case GENRE_MODEL:
|
||||
return title.genres;
|
||||
case KEYWORD_MODEL:
|
||||
return title.keywords;
|
||||
case PRODUCTION_COUNTRY_MODEL:
|
||||
return title.production_countries;
|
||||
}
|
||||
}
|
||||
|
||||
interface DetachTagDialogProps {
|
||||
tag: TitleTag;
|
||||
}
|
||||
function DetachTagDialog({tag}: DetachTagDialogProps) {
|
||||
const {close} = useDialogContext();
|
||||
const detachTag = useDetachTitleTag(tag);
|
||||
const modelName = tag.model_type.replace('_', ' ');
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
isLoading={detachTag.isPending}
|
||||
isDanger
|
||||
title={<Trans message="Detach :name" values={{name: modelName}} />}
|
||||
body={
|
||||
<Trans
|
||||
message="Are you sure you want to detach this :name?"
|
||||
values={{name: modelName}}
|
||||
/>
|
||||
}
|
||||
confirm={<Trans message="Detach" />}
|
||||
onConfirm={() => {
|
||||
detachTag.mutate(undefined, {
|
||||
onSuccess: () => close(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
219
resources/client/admin/titles/title-editor/videos-editor/title-videos-editor.tsx
Executable file
219
resources/client/admin/titles/title-editor/videos-editor/title-videos-editor.tsx
Executable file
@@ -0,0 +1,219 @@
|
||||
import {Link, useOutletContext, useParams} from 'react-router-dom';
|
||||
import {VideoThumbnail} from '@app/videos/video-thumbnail';
|
||||
import {PlayCircleIcon} from '@common/icons/material/PlayCircle';
|
||||
import {Video} from '@app/titles/models/video';
|
||||
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
|
||||
import {FormattedDate} from '@common/i18n/formatted-date';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {EditIcon} from '@common/icons/material/Edit';
|
||||
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
|
||||
import {AddIcon} from '@common/icons/material/Add';
|
||||
import {InfiniteScrollSentinel} from '@common/ui/infinite-scroll/infinite-scroll-sentinel';
|
||||
import {AddFilterButton} from '@common/datatable/filters/add-filter-button';
|
||||
import {TuneIcon} from '@common/icons/material/Tune';
|
||||
import React, {Fragment, useMemo} from 'react';
|
||||
import {useBackendFilterUrlParams} from '@common/datatable/filters/backend-filter-url-params';
|
||||
import {CompactSeasonEpisode} from '@app/episodes/compact-season-episode';
|
||||
import {FilterList} from '@common/datatable/filters/filter-list/filter-list';
|
||||
import {TitleVideosSortButton} from '@app/admin/titles/title-editor/videos-editor/title-videos-sort-button';
|
||||
import {DeleteIcon} from '@common/icons/material/Delete';
|
||||
import {VideoGridItemBottomGradient} from '@app/titles/video-grid';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
|
||||
import {useDeleteVideos} from '@app/admin/videos/requests/use-delete-videos';
|
||||
import {VideosEditorSeasonSelect} from '@app/admin/titles/title-editor/videos-editor/videos-editor-season-select';
|
||||
import {VideosDatatableFilters} from '@app/admin/videos/videos-datatable-filters';
|
||||
import {TitleEditorLayout} from '@app/admin/titles/title-editor/title-editor-layout';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
|
||||
import {MediaPlayIcon} from '@common/icons/media/media-play';
|
||||
import {TitleEditorPageStatus} from '@app/admin/titles/title-editor/title-editor-page-status';
|
||||
|
||||
export function TitleVideosEditor() {
|
||||
const filters = useMemo(
|
||||
() => VideosDatatableFilters.filter(f => f.key !== 'title_id'),
|
||||
[],
|
||||
);
|
||||
const {encodedFilters} = useBackendFilterUrlParams(filters);
|
||||
const {season, episode} = useParams();
|
||||
const title = useOutletContext<Title>();
|
||||
const query = useInfiniteData<Video>({
|
||||
queryKey: ['video', 'edit-title-page'],
|
||||
endpoint: 'videos',
|
||||
defaultOrderBy: 'created_at',
|
||||
defaultOrderDir: 'desc',
|
||||
queryParams: {
|
||||
perPage: 20,
|
||||
filters: encodedFilters,
|
||||
title_id: title.id,
|
||||
season: season ?? null,
|
||||
episode: episode ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
let content;
|
||||
|
||||
if (query.data) {
|
||||
content = query.items.length ? (
|
||||
<Fragment>
|
||||
<div className="grid grid-cols-1 gap-24 md:grid-cols-2 lg:grid-cols-3">
|
||||
{query.items.map(video => (
|
||||
<VideoItem key={video.id} video={video} />
|
||||
))}
|
||||
</div>
|
||||
<InfiniteScrollSentinel query={query} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<NoVideosMessage isFiltering={encodedFilters != null} />
|
||||
);
|
||||
} else {
|
||||
content = <TitleEditorPageStatus query={query} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleEditorLayout>
|
||||
<div className="mb-24 flex flex-wrap items-center gap-12">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="primary"
|
||||
startIcon={<AddIcon />}
|
||||
elementType={Link}
|
||||
to="new"
|
||||
className="mr-auto"
|
||||
>
|
||||
<Trans message="Add video" />
|
||||
</Button>
|
||||
<VideosEditorSeasonSelect title={title} />
|
||||
<TitleVideosSortButton
|
||||
value={`${query.sortDescriptor.orderBy}:${query.sortDescriptor.orderDir}`}
|
||||
onValueChange={value => {
|
||||
const [orderBy, orderDir] = value.split(':');
|
||||
query.setSortDescriptor({orderBy, orderDir: orderDir as any});
|
||||
}}
|
||||
/>
|
||||
<AddFilterButton
|
||||
icon={<TuneIcon />}
|
||||
color={null}
|
||||
variant="outline"
|
||||
filters={filters}
|
||||
/>
|
||||
</div>
|
||||
<FilterList className="mb-24" filters={filters} />
|
||||
{content}
|
||||
</TitleEditorLayout>
|
||||
);
|
||||
}
|
||||
|
||||
interface NoVideosMessageProps {
|
||||
isFiltering: boolean;
|
||||
}
|
||||
function NoVideosMessage({isFiltering}: NoVideosMessageProps) {
|
||||
return (
|
||||
<IllustratedMessage
|
||||
className="mt-40"
|
||||
imageMargin="mb-8"
|
||||
image={
|
||||
<div className="text-muted">
|
||||
<MediaPlayIcon size="xl" />
|
||||
</div>
|
||||
}
|
||||
imageHeight="h-auto"
|
||||
title={
|
||||
isFiltering ? (
|
||||
<Trans message="No matching videos" />
|
||||
) : (
|
||||
<Trans message="No videos have been added yet" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoItemProps {
|
||||
video: Video;
|
||||
}
|
||||
function VideoItem({video}: VideoItemProps) {
|
||||
const link = getWatchLink(video);
|
||||
return (
|
||||
<div className="">
|
||||
<Link to={link} className="relative isolate block" target="_blank">
|
||||
<VideoThumbnail video={video} title={video.title} srcSize="lg" />
|
||||
<VideoGridItemBottomGradient />
|
||||
<span className="absolute bottom-0 left-0 z-30 flex items-center gap-x-6 p-10 text-white">
|
||||
<PlayCircleIcon />
|
||||
<span className="capitalize">{video.category}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="mb-4 mt-12 flex items-center gap-24">
|
||||
<Link to={link} className="block font-semibold hover:underline">
|
||||
{video.name}
|
||||
</Link>
|
||||
{video.reports_count ? (
|
||||
<div className="ml-auto flex-shrink-0 whitespace-nowrap text-sm text-muted">
|
||||
<Trans
|
||||
message=":count reports"
|
||||
values={{count: video.reports_count}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-14 text-sm text-muted">
|
||||
{(video.season_num != null || video.episode_num != null) && (
|
||||
<CompactSeasonEpisode
|
||||
seasonNum={video.season_num}
|
||||
episodeNum={video.episode_num}
|
||||
/>
|
||||
)}
|
||||
<FormattedDate date={video.created_at} />
|
||||
</div>
|
||||
<div className="mt-14 flex items-center gap-24">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
startIcon={<EditIcon />}
|
||||
elementType={Link}
|
||||
to={`edit/${video.id}`}
|
||||
>
|
||||
<Trans message="Edit" />
|
||||
</Button>
|
||||
<DeleteButton video={video} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeleteButtonProps {
|
||||
video: Video;
|
||||
}
|
||||
function DeleteButton({video}: DeleteButtonProps) {
|
||||
const deleteVideos = useDeleteVideos();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="modal"
|
||||
onClose={confirmed => {
|
||||
if (confirmed) {
|
||||
deleteVideos.mutate({videoIds: [video.id]});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
startIcon={<DeleteIcon />}
|
||||
disabled={deleteVideos.isPending}
|
||||
>
|
||||
<Trans message="Delete" />
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
isDanger
|
||||
title={<Trans message="Delete video" />}
|
||||
body={<Trans message="Are you sure you want to delete this video?" />}
|
||||
confirm={<Trans message="Delete" />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
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: 'upvotes:desc',
|
||||
label: message('Most upvotes'),
|
||||
},
|
||||
{
|
||||
value: 'reports_count:desc',
|
||||
label: message('Most reported'),
|
||||
},
|
||||
{
|
||||
value: 'season_num:desc',
|
||||
label: message('Seasons'),
|
||||
},
|
||||
{
|
||||
value: 'order:asc',
|
||||
label: message('Curated'),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onValueChange: (newValue: string) => void;
|
||||
color?: ButtonProps['color'];
|
||||
}
|
||||
export function TitleVideosSortButton({value, onValueChange, color}: 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.map(option => (
|
||||
<MenuItem value={option.value} key={option.value}>
|
||||
<Trans {...option.label} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</MenuTrigger>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {Select} from '@common/ui/forms/select/select';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {Option} from '@common/ui/forms/combobox/combobox';
|
||||
import React from 'react';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {useParams} from 'react-router-dom';
|
||||
import {useNavigate} from '@common/utils/hooks/use-navigate';
|
||||
import {Title} from '@app/titles/models/title';
|
||||
import {useSeasonEpisodeNumbers} from '@app/seasons/requests/use-season-episode-numbers';
|
||||
|
||||
interface Props {
|
||||
title: Title;
|
||||
}
|
||||
export function VideosEditorSeasonSelect({title}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const {trans} = useTrans();
|
||||
const params = useParams();
|
||||
const season = params.season ? Number(params.season) : '';
|
||||
const episode = params.episode ? Number(params.episode) : '';
|
||||
|
||||
const handleNavigate = (season?: number, episode?: number) => {
|
||||
let uri = `/admin/titles/${title.id}/edit/videos`;
|
||||
if (season) {
|
||||
uri += `/seasons/${season}`;
|
||||
}
|
||||
if (episode) {
|
||||
uri += `/episodes/${episode}`;
|
||||
}
|
||||
navigate(uri);
|
||||
};
|
||||
|
||||
if (!title.seasons_count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-12">
|
||||
<Select
|
||||
className="flex-1"
|
||||
selectedValue={season}
|
||||
onSelectionChange={newSeason => {
|
||||
handleNavigate(newSeason as number);
|
||||
}}
|
||||
placeholder={trans(message('Season'))}
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
>
|
||||
<Option key="none" value="">
|
||||
<Trans message="All seasons" />
|
||||
</Option>
|
||||
{[...new Array(title.seasons_count).keys()].map(i => {
|
||||
const number = i + 1;
|
||||
return (
|
||||
<Option key={number} value={number}>
|
||||
<Trans message="Season :number" values={{number}} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
{season && (
|
||||
<EpisodeSelect
|
||||
value={episode}
|
||||
onChange={newEpisode => {
|
||||
handleNavigate(season, newEpisode as number);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EpisodeSelectProps {
|
||||
value: string | number;
|
||||
onChange: (value: string | number) => void;
|
||||
}
|
||||
function EpisodeSelect({value, onChange}: EpisodeSelectProps) {
|
||||
const {trans} = useTrans();
|
||||
const {data} = useSeasonEpisodeNumbers();
|
||||
return (
|
||||
<Select
|
||||
placeholder={trans(message('Episode'))}
|
||||
selectionMode="single"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
selectedValue={value}
|
||||
onSelectionChange={onChange}
|
||||
>
|
||||
<Option key="none" value="">
|
||||
<Trans message="All episodes" />
|
||||
</Option>
|
||||
{data?.episodeNumbers.map(number => {
|
||||
return (
|
||||
<Option key={number} value={number}>
|
||||
<Trans message="Episode :number" values={{number}} />
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user