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