first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,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>
);
}

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

View File

@@ -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" />}
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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} />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()});
}}
/>
);
}

View File

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

View File

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

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

View 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}`;

View 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 />;
}

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

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

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

View File

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

View File

@@ -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(),
});
}}
/>
);
}

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

View File

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

View File

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