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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,45 @@
import {useForm} from 'react-hook-form';
import {
CreatePersonPayload,
useCreatePerson,
} from '@app/admin/people/requests/use-create-person';
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {PersonPrimaryFactsForm} from '@app/admin/people/crupdate/person-primary-facts-form';
export function CreatePersonPage() {
const navigate = useNavigate();
const form = useForm<CreatePersonPayload>({
defaultValues: {
gender: 'female',
known_for: 'Acting',
popularity: 3,
},
});
const createPerson = useCreatePerson(form);
return (
<CrupdateResourceLayout
onSubmit={values =>
createPerson.mutate(values, {
onSuccess: response => {
navigate(`../${response.person.id}/edit`, {
relative: 'path',
replace: true,
});
},
})
}
form={form}
title={<Trans message="New person" />}
isLoading={createPerson.isPending}
disableSaveWhenNotDirty
>
<FileUploadProvider>
<PersonPrimaryFactsForm />
</FileUploadProvider>
</CrupdateResourceLayout>
);
}

View File

@@ -0,0 +1,156 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {PersonCredit, TitleCredit} from '@app/titles/models/title';
import {Trans} from '@common/i18n/trans';
import React, {Fragment, useContext, useMemo} 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 {TitlePoster} from '@app/titles/title-poster/title-poster';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {IconButton} from '@common/ui/buttons/icon-button';
import {DeleteIcon} from '@common/icons/material/Delete';
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
import {useOutletContext} from 'react-router-dom';
import {GetPersonResponse} from '@app/people/requests/use-person';
import {IllustratedMessage} from '@common/ui/images/illustrated-message';
import {RecentActorsIcon} from '@common/icons/material/RecentActors';
import {TitleLink} from '@app/titles/title-link';
import {useDeletePersonCredit} from '@app/admin/people/requests/use-delete-person-credit';
const columnConfig: ColumnConfig<PersonCredit>[] = [
{
key: 'name',
header: () => <Trans message="Credit" />,
visibleInMode: 'all',
width: 'flex-3',
body: credit => (
<div className="flex items-center gap-12">
<TitlePoster title={credit} srcSize="sm" size="w-32" />
<div className="overflow-hidden min-w-0">
<div className="overflow-hidden overflow-ellipsis">
<TitleLink title={credit} target="_blank" />
</div>
<div className="text-muted text-xs overflow-hidden overflow-ellipsis">
{credit.is_series ? (
<Trans message="Series" />
) : (
<Trans message="Movie" />
)}
</div>
</div>
</div>
),
},
{
key: 'year',
header: () => <Trans message="Year" />,
body: credit => credit.year,
},
{
key: 'character',
header: () => <Trans message="Character" />,
body: credit => (credit.pivot.character ? credit.pivot.character : '-'),
},
{
key: 'department',
header: () => <Trans message="Department" />,
body: credit => (
<span className="capitalize">{credit.pivot.department}</span>
),
},
{
key: 'job',
header: () => <Trans message="Job" />,
body: credit => <span className="capitalize">{credit.pivot.job}</span>,
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
align: 'end',
width: 'w-42 flex-shrink-0',
visibleInMode: 'all',
body: item => (
<div className="text-muted">
<DeleteButton credit={item} />
</div>
),
},
];
export function PersonCreditsEditor() {
const data = useOutletContext<GetPersonResponse>();
const credits = useMemo(() => {
return Object.values(data.credits)
.flat()
.filter(credit => credit.pivot != null);
}, [data.credits]);
return (
<Fragment>
<Table
enableSelection={false}
columns={columnConfig}
data={credits}
cellHeight="h-54"
tableBody={<CreditsTableBody />}
/>
{!credits.length && <NoCreditsMessage />}
</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>
);
}
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" />}
/>
);
}
interface DeleteButtonProps {
credit: PersonCredit;
}
function DeleteButton({credit}: DeleteButtonProps) {
const deleteCredit = useDeletePersonCredit(credit);
return (
<DialogTrigger type="modal">
<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" />}
isLoading={deleteCredit.isPending}
onConfirm={() => deleteCredit.mutate()}
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,113 @@
import React, {Fragment, useMemo} from 'react';
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 {FormSelect, Option} from '@common/ui/forms/select/select';
import {useValueLists} from '@common/http/value-lists';
import {Item} from '@common/ui/forms/listbox/item';
export function PersonPrimaryFactsForm() {
return (
<Fragment>
<div className="mb-24 gap-24 md:flex">
<FormImageSelector
variant="square"
previewSize="w-204 aspect-poster"
name="poster"
diskPrefix="person-posters"
label={<Trans message="Poster" />}
showRemoveButton
/>
<div className="flex-auto max-md:mt-24">
<FormTextField
name="name"
label={<Trans message="Name" />}
className="mb-24"
required
/>
<KnownForField />
<FormDatePicker
name="birth_date"
label={<Trans message="Birth date" />}
className="mb-24"
granularity="day"
/>
<FormDatePicker
name="death_date"
label={<Trans message="Death date" />}
granularity="day"
/>
</div>
</div>
<FormTextField
name="description"
label={<Trans message="Biography" />}
inputElementType="textarea"
rows={4}
className="mb-24"
/>
<div className="mb-24 items-center gap-24 md:flex">
<FormTextField
name="birth_place"
label={<Trans message="Birth place" />}
className="flex-1 max-md:mb-24"
/>
</div>
<div className="mb-24 items-center gap-24 md:flex">
<FormSelect
name="gender"
label={<Trans message="Gender" />}
className="flex-1 max-md:mb-24"
selectionMode="single"
>
<Option value="male">
<Trans message="Male" />
</Option>
<Option value="female">
<Trans message="Female" />
</Option>
</FormSelect>
</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"
/>
</div>
</Fragment>
);
}
function KnownForField() {
const {data} = useValueLists(['tmdbDepartments']);
const departments = useMemo(() => {
return data?.tmdbDepartments.map(item => {
if (item.department === 'Actors') {
return {department: 'Acting'};
}
return {department: item.department};
});
}, [data]);
return (
<FormSelect
name="known_for"
label={<Trans message="Known for" />}
required
items={departments}
className="mb-24"
selectionMode="single"
showSearchField
>
{item => (
<Item value={item.department}>
<Trans message={item.department} />
</Item>
)}
</FormSelect>
);
}

View File

@@ -0,0 +1,93 @@
import {useForm} from 'react-hook-form';
import {CreatePersonPayload} from '@app/admin/people/requests/use-create-person';
import {CrupdateResourceLayout} from '@common/admin/crupdate-resource-layout';
import {Trans} from '@common/i18n/trans';
import React, {Fragment} from 'react';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {useUpdatePerson} from '@app/admin/people/requests/use-update-person';
import {GetPersonResponse, usePerson} from '@app/people/requests/use-person';
import {PageMetaTags} from '@common/http/page-meta-tags';
import {PageStatus} from '@common/http/page-status';
import {Tabs} from '@common/ui/tabs/tabs';
import {TabList} from '@common/ui/tabs/tab-list';
import {Tab} from '@common/ui/tabs/tab';
import {Link, Outlet, useLocation} from 'react-router-dom';
export function UpdatePersonPage() {
const query = usePerson('editPersonPage');
return query.data ? (
<Fragment>
<PageMetaTags query={query} />
<PageContent data={query.data} />
</Fragment>
) : (
<div className="relative h-full w-full">
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
</div>
);
}
interface PageContentProps {
data: GetPersonResponse;
}
function PageContent({data}: PageContentProps) {
const {person} = data;
const navigate = useNavigate();
const form = useForm<CreatePersonPayload>({
defaultValues: {
name: person.name,
known_for: person.known_for,
poster: person.poster,
birth_date: person.birth_date,
death_date: person.death_date,
birth_place: person.birth_place,
description: person.description,
gender: person.gender,
popularity: person.popularity,
},
});
const updatePersonPage = useUpdatePerson(form);
const {pathname} = useLocation();
const tabName = pathname.split('/').pop();
const selectedTab = tabName === 'credits' ? 1 : 0;
return (
<CrupdateResourceLayout
onSubmit={values =>
updatePersonPage.mutate(values, {
onSuccess: () => {
navigate('../../../', {relative: 'path', replace: true});
},
})
}
form={form}
title={<Trans values={{name: person.name}} message="Edit “:name“" />}
isLoading={updatePersonPage.isPending}
disableSaveWhenNotDirty
>
<Tabs selectedTab={selectedTab}>
<TabList>
<Tab
elementType={Link}
to={`../primary-facts`}
relative="path"
replace
>
<Trans message="Primary facts" />
</Tab>
<Tab elementType={Link} to={`../credits`} relative="path" replace>
<Trans message="Credits" />
</Tab>
</TabList>
<div className="min-h-512 pt-24">
<FileUploadProvider>
<Outlet context={data} />
</FileUploadProvider>
</div>
</Tabs>
</CrupdateResourceLayout>
);
}

View File

@@ -0,0 +1,87 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {Trans} from '@common/i18n/trans';
import {FormattedDate} from '@common/i18n/formatted-date';
import {Link} from 'react-router-dom';
import {IconButton} from '@common/ui/buttons/icon-button';
import {EditIcon} from '@common/icons/material/Edit';
import React from 'react';
import {FormattedNumber} from '@common/i18n/formatted-number';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {PersonPoster} from '@app/people/person-poster/person-poster';
import {Person} from '@app/titles/models/person';
import {PersonLink} from '@app/people/person-link';
import {KnownForCompact} from '@app/people/known-for-compact';
export const PeopleDatatableColumns: ColumnConfig<Person>[] = [
{
key: 'name',
allowsSorting: true,
width: 'flex-3',
visibleInMode: 'all',
header: () => <Trans message="Person" />,
body: person => (
<div className="flex items-center gap-12">
<PersonPoster person={person} srcSize="sm" size="w-32" rounded />
<div className="overflow-hidden min-w-0">
<div className="overflow-hidden overflow-ellipsis">
<PersonLink person={person} target="_blank" />
</div>
<div className="text-muted text-xs overflow-hidden overflow-ellipsis">
<KnownForCompact
person={person}
linkTarget="_blank"
linkColor="inherit"
/>
</div>
</div>
</div>
),
},
{
key: 'birth_date',
allowsSorting: true,
header: () => <Trans message="Birth date" />,
body: person => <FormattedDate date={person.birth_date} />,
},
{
key: 'views',
allowsSorting: true,
header: () => <Trans message="Page views" />,
body: person =>
person.views ? <FormattedNumber value={person.views} /> : null,
width: 'w-124 flex-shrink-0',
},
{
key: 'popularity',
allowsSorting: true,
header: () => <Trans message="Popularity" />,
body: person =>
person.popularity ? <FormattedNumber value={person.popularity} /> : null,
width: 'w-124 flex-shrink-0',
},
{
key: 'updated_at',
allowsSorting: true,
width: 'w-124 flex-shrink-0',
header: () => <Trans message="Last updated" />,
body: person =>
person.updated_at ? <FormattedDate date={person.updated_at} /> : '',
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
visibleInMode: 'all',
align: 'end',
width: 'w-42 flex-shrink-0',
body: video => (
<Link to={`${video.id}/edit/primary-facts`} className="text-muted">
<Tooltip label={<Trans message="Edit" />}>
<IconButton size="md">
<EditIcon />
</IconButton>
</Tooltip>
</Link>
),
},
];

View File

@@ -0,0 +1,120 @@
import {
ALL_PRIMITIVE_OPERATORS,
BackendFilter,
FilterControlType,
FilterOperator,
} from '@common/datatable/filters/backend-filter';
import {message} from '@common/i18n/message';
import {
createdAtFilter,
timestampFilter,
updatedAtFilter,
} from '@common/datatable/filters/timestamp-filters';
export const PeopleDatatableFilters: BackendFilter[] = [
{
key: 'known_for',
label: message('Known for'),
description: message('What role is person known for'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.Select,
defaultValue: 'acting',
options: [
{
label: message('Acting'),
key: 'acting',
value: 'acting',
},
{
label: message('Directing'),
key: 'directing',
value: 'directing',
},
{
label: message('Production'),
key: 'production',
value: 'production',
},
{label: message('Writing'), key: 'writing', value: 'writing'},
{label: message('Crew'), key: 'crew', value: 'crew'},
{label: message('Art'), key: 'art', value: 'art'},
{
label: message('Costume & Make-Up'),
key: 'Costume & Make-Up',
value: 'Costume & Make-Up',
},
{label: message('Camera'), key: 'camera', value: 'camera'},
{label: message('Editing'), key: 'editing', value: 'editing'},
{
label: message('Visual Effects'),
key: 'visual effects',
value: 'visual effects',
},
{label: message('Sound'), key: 'sound', value: 'sound'},
{label: message('Lighting'), key: 'lighting', value: 'lighting'},
{label: message('Creator'), key: 'creator', value: 'creator'},
],
},
},
{
key: 'gender',
label: message('Gender'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.Select,
defaultValue: 'male',
options: [
{
label: message('Male'),
key: 'male',
value: 'male',
},
{
label: message('Female'),
key: 'female',
value: 'female',
},
],
},
},
{
key: 'poster',
label: message('No poster'),
description: message('Whether person has a poster'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.BooleanToggle,
defaultValue: null,
},
},
{
key: 'views',
label: message('Page views'),
description: message('Number of unique page views'),
defaultOperator: FilterOperator.lte,
operators: ALL_PRIMITIVE_OPERATORS,
control: {
type: FilterControlType.Input,
inputType: 'number',
minValue: 1,
defaultValue: 100,
},
},
timestampFilter({
key: 'birth_date',
label: message('Birth date'),
description: message('Date person was born'),
}),
timestampFilter({
key: 'death_date',
label: message('Death date'),
description: message('Date person died'),
}),
createdAtFilter({
description: message('Date person was created'),
}),
updatedAtFilter({
description: message('Date person was last updated'),
}),
];

View File

@@ -0,0 +1,72 @@
import React, {Fragment} from 'react';
import {DataTablePage} from '@common/datatable/page/data-table-page';
import {Trans} from '@common/i18n/trans';
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
import awardsImage from './awards.svg';
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
import {Link} from 'react-router-dom';
import {useSettings} from '@common/core/settings/use-settings';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {IconButton} from '@common/ui/buttons/icon-button';
import {PublishIcon} from '@common/icons/material/Publish';
import {ImportSingleFromTmdbDialog} from '@app/admin/titles/import/import-single-from-tmdb-dialog';
import {PeopleDatatableColumns} from '@app/admin/people/people-datatable-columns';
import {PeopleDatatableFilters} from '@app/admin/people/people-datatable-filters';
import {PERSON_MODEL} from '@app/titles/models/person';
export function PeopleDatatablePage() {
return (
<DataTablePage
endpoint="people"
title={<Trans message="People" />}
columns={PeopleDatatableColumns}
filters={PeopleDatatableFilters}
actions={<Actions />}
selectedActions={<DeleteSelectedItemsAction />}
emptyStateMessage={
<DataTableEmptyStateMessage
image={awardsImage}
title={<Trans message="No people have been created yet" />}
filteringTitle={<Trans message="No matching people" />}
/>
}
/>
);
}
function Actions() {
const {tmdb_is_setup} = useSettings();
const navigate = useNavigate();
return (
<Fragment>
{tmdb_is_setup && (
<DialogTrigger
type="modal"
onClose={item => {
if (item) {
navigate(`/admin/people/${item.id}/edit/primary-facts`);
}
}}
>
<Tooltip label={<Trans message="Import using TheMovieDB ID" />}>
<IconButton
variant="outline"
color="primary"
className="flex-shrink-0"
size="sm"
>
<PublishIcon />
</IconButton>
</Tooltip>
<ImportSingleFromTmdbDialog modelType={PERSON_MODEL} />
</DialogTrigger>
)}
<DataTableAddItemButton elementType={Link} to="new">
<Trans message="Add person" />
</DataTableAddItemButton>
</Fragment>
);
}

View File

@@ -0,0 +1,39 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {Person} from '@app/titles/models/person';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {
person: Person;
}
export interface CreatePersonPayload {
name: string;
known_for: string;
poster: string;
birth_date: string;
death_date: string;
birth_place: string;
description: string;
gender: string;
popularity: number;
}
export function useCreatePerson(form: UseFormReturn<CreatePersonPayload>) {
return useMutation({
mutationFn: (payload: CreatePersonPayload) => createPerson(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['people']});
toast(message('Person created'));
},
onError: r => onFormQueryError(r, form),
});
}
function createPerson(payload: CreatePersonPayload): Promise<Response> {
return apiClient.post(`people`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,42 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {titleCreditsQueryKey} from '@app/admin/titles/requests/use-title-credits';
import {PersonCredit} from '@app/titles/models/title';
import {useParams} from 'react-router-dom';
interface Response extends BackendResponse {}
export function useDeletePersonCredit(credit: PersonCredit) {
const {personId} = useParams();
return useMutation({
mutationFn: () =>
deleteCredit(credit.id, undefined, undefined, credit.pivot.id),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: titleCreditsQueryKey(credit.id),
});
await queryClient.invalidateQueries({
queryKey: ['people', `${personId}`],
});
toast(message('Credit deleted'));
},
onError: r => showHttpErrorToast(r),
});
}
function deleteCredit(
titleId: number | string,
season: string | undefined,
episode: string | undefined,
creditId: number | string,
): Promise<Response> {
return apiClient
.delete(`titles/${titleId}/credits/${creditId}`, {
params: {season, episode},
})
.then(r => r.data);
}

View File

@@ -0,0 +1,36 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {Person} from '@app/titles/models/person';
import {CreatePersonPayload} from '@app/admin/people/requests/use-create-person';
import {useParams} from 'react-router-dom';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {
person: Person;
}
export function useUpdatePerson(form: UseFormReturn<CreatePersonPayload>) {
const {personId} = useParams();
return useMutation({
mutationFn: (payload: CreatePersonPayload) =>
updatePerson(payload, personId!),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['people', `${personId}`],
});
toast(message('Person updated'));
},
onError: r => onFormQueryError(r, form),
});
}
function updatePerson(
payload: CreatePersonPayload,
personId: string,
): Promise<Response> {
return apiClient.put(`people/${personId}`, payload).then(r => r.data);
}