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

1638
resources/client-translations.json Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
import {Navigate, RouteObject} from 'react-router-dom';
import React from 'react';
import {ChannelsDatatablePage} from '@common/admin/channels/channels-datatable-page';
import {EditChannelPage} from '@app/admin/channels/edit-channel-page';
import {CreateChannelPage} from '@app/admin/channels/create-channel-page';
import {NewsDatatablePage} from '@app/admin/news/news-datatable-page';
import {CommentsDatatablePage} from '@common/comments/comments-datatable-page/comments-datatable-page';
import {ReviewsDatatablePage} from '@app/admin/reviews/reviews-datatable-page';
import {VideosDatatablePage} from '@app/admin/videos/videos-datatable-page';
import {CreateVideoPage} from '@app/admin/videos/crupdate/create-video-page';
import {EditVideoPage} from '@app/admin/videos/crupdate/edit-video-page';
import {TitlesDatatablePage} from '@app/admin/titles/titles-datatable-page';
import {EditTitlePage} from '@app/admin/titles/title-editor/edit-title-page';
import {SeasonEditorEpisodeList} from '@app/admin/titles/title-editor/seasons-editor/season-editor-episode-list';
import {TitleSeasonsEditor} from '@app/admin/titles/title-editor/seasons-editor/title-seasons-editor';
import {TitlePrimaryFactsForm} from '@app/admin/titles/title-editor/title-primary-facts-form';
import {TitleReviewsEditor} from '@app/admin/titles/title-editor/title-reviews-editor';
import {TitleImagesEditor} from '@app/admin/titles/title-editor/title-images-editor';
import {TitleVideosEditor} from '@app/admin/titles/title-editor/videos-editor/title-videos-editor';
import {EpisodePrimaryFactsForm} from '@app/admin/titles/title-editor/episode-editor/episode-primary-facts-form';
import {EpisodeCastEditor} from '@app/admin/titles/title-editor/episode-editor/episode-cast-editor';
import {TitleCastEditor} from '@app/admin/titles/title-editor/credits-editor/title-cast-editor';
import {TitleCrewEditor} from '@app/admin/titles/title-editor/credits-editor/title-crew-editor';
import {SeasonCastEditor} from '@app/admin/titles/title-editor/seasons-editor/season-cast-editor';
import {SeasonCrewEditor} from '@app/admin/titles/title-editor/seasons-editor/season-crew-editor';
import {EpisodeCrewEditor} from '@app/admin/titles/title-editor/episode-editor/episode-crew-editor';
import {TitleTagsEditor} from '@app/admin/titles/title-editor/title-tags-editor/title-tags-editor';
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 {TitleCommentsEditor} from '@app/admin/titles/title-editor/title-comments-editor';
import {PeopleDatatablePage} from '@app/admin/people/people-datatable-page';
import {CreatePersonPage} from '@app/admin/people/crupdate/create-person-page';
import {UpdatePersonPage} from '@app/admin/people/crupdate/update-person-page';
import {PersonPrimaryFactsForm} from '@app/admin/people/crupdate/person-primary-facts-form';
import {PersonCreditsEditor} from '@app/admin/people/crupdate/person-credits-editor';
import {AuthRoute} from '@common/auth/guards/auth-route';
import {EditNewsArticlePage} from '@app/admin/news/edit-news-article-page';
import {CreateNewsArticlePage} from '@app/admin/news/create-news-article-page';
import {TitleTagsDatatablePage} from '@app/admin/title-tags/title-tags-editor/title-tags-datatable-page';
import {ListsDatatablePage} from '@app/admin/lists/lists-datatable-page';
import {CreateUserListPage} from '@app/user-lists/pages/create-user-list-page';
import {EditUserListPage} from '@app/user-lists/pages/edit-user-list-page';
import {MtdbAdminReportPage} from '@app/admin/reports/mtdb-admin-report-page';
import {AdminInsightsReport} from '@app/admin/reports/admin-insights-report';
import {AdminVisitorsReport} from '@app/admin/reports/admin-visitors-report';
import {TitleInsightsPage} from '@app/admin/reports/pages/title-insights-page';
import {EpisodeInsightsPage} from '@app/admin/reports/pages/episode-insights-page';
import {SeasonInsightsPage} from '@app/admin/reports/pages/season-insights-page';
import {VideoInsightsPage} from '@app/admin/reports/pages/video-insights-page';
export const AppAdminRoutes: RouteObject[] = [
// Reports
{
path: '/',
element: <MtdbAdminReportPage />,
children: [
{index: true, element: <AdminInsightsReport />},
{path: 'plays', element: <AdminInsightsReport />},
{path: 'visitors', element: <AdminVisitorsReport />},
],
},
// Channels
{
path: 'channels',
element: <ChannelsDatatablePage />,
},
{
path: 'channels/new',
element: <CreateChannelPage />,
},
{
path: 'channels/:slugOrId/edit',
element: <EditChannelPage />,
},
// User lists
{
path: 'lists',
element: <ListsDatatablePage />,
},
{
path: 'lists/new',
element: <CreateUserListPage />,
},
{
path: 'lists/:slugOrId/edit',
element: <EditUserListPage />,
},
// People
{
path: 'people',
element: <PeopleDatatablePage />,
},
{
path: 'people/new',
element: <CreatePersonPage />,
},
{
path: 'people/:personId/edit',
element: <UpdatePersonPage />,
children: [
{
index: true,
element: <Navigate to="primary-facts" replace />,
},
{
path: 'primary-facts',
element: <PersonPrimaryFactsForm />,
},
{
path: 'credits',
element: <PersonCreditsEditor />,
},
],
},
// Titles
{
path: 'titles',
element: <TitlesDatatablePage />,
},
{
path: 'titles/new',
element: <TitlePrimaryFactsForm />,
},
{
path: 'videos/:videoId/insights',
element: <VideoInsightsPage />,
},
{
path: 'titles/:titleId/insights',
element: <TitleInsightsPage />,
},
{
path: 'titles/:titleId/insights/seasons/:season',
element: <SeasonInsightsPage />,
},
{
path: 'titles/:titleId/insights/seasons/:season/episodes/:episode',
element: <EpisodeInsightsPage />,
},
{
path: 'titles/:titleId/edit',
element: <Navigate to="primary-facts" replace={true} />,
},
{
path: 'titles/:titleId/edit',
element: <EditTitlePage />,
children: [
{
index: true,
element: <TitlePrimaryFactsForm />,
},
{
path: 'primary-facts',
element: <TitlePrimaryFactsForm />,
},
{
path: 'reviews',
element: <TitleReviewsEditor />,
},
{
path: 'comments',
element: <TitleCommentsEditor />,
},
{
path: 'images',
element: <TitleImagesEditor />,
},
{
path: 'genres',
element: <TitleTagsEditor type={GENRE_MODEL} />,
},
{
path: 'keywords',
element: <TitleTagsEditor type={KEYWORD_MODEL} />,
},
{
path: 'countries',
element: <TitleTagsEditor type={PRODUCTION_COUNTRY_MODEL} />,
},
{
path: 'cast',
element: <TitleCastEditor />,
},
{
path: 'crew',
element: <TitleCrewEditor />,
},
{
path: 'videos',
element: <TitleVideosEditor />,
},
{
path: 'videos/seasons/:season',
element: <TitleVideosEditor />,
},
{
path: 'videos/seasons/:season/episodes/:episode',
element: <TitleVideosEditor />,
},
// SEASONS
{
path: 'seasons',
element: <TitleSeasonsEditor />,
},
{
path: 'seasons/:season',
children: [
{
index: true,
element: <Navigate to="episodes" replace />,
},
{
path: 'Episodes',
element: <SeasonEditorEpisodeList />,
},
{
path: 'cast',
element: <SeasonCastEditor />,
},
{
path: 'crew',
element: <SeasonCrewEditor />,
},
],
},
// EPISODES
{
path: 'seasons/:season/episodes/new',
element: <EpisodePrimaryFactsForm />,
},
{
path: 'seasons/:season/episodes/:episode',
children: [
{
index: true,
element: <Navigate to="primary-facts" replace />,
},
{
path: 'primary-facts',
element: <EpisodePrimaryFactsForm />,
},
{
path: 'cast',
element: <EpisodeCastEditor />,
},
{
path: 'crew',
element: <EpisodeCrewEditor />,
},
],
},
],
},
// Video editor with no season or episode selected
{
path: 'titles/:titleId/edit/videos/new',
element: <CreateVideoPage />,
},
{
path: 'titles/:titleId/edit/videos/edit/:videoId',
element: <EditVideoPage />,
},
// Video editor with season selected
{
path: 'titles/:titleId/edit/videos/seasons/:season/new',
element: <CreateVideoPage />,
},
{
path: 'titles/:titleId/edit/videos/seasons/:season/edit/:videoId',
element: <EditVideoPage />,
},
// Video editor with season and episode selected
{
path: 'titles/:titleId/edit/videos/seasons/:season/episodes/:episode/new',
element: <CreateVideoPage />,
},
{
path: 'titles/:titleId/edit/videos/seasons/:season/episodes/:episode/edit/:videoId',
element: <EditVideoPage />,
},
// News articles
{
path: 'news',
element: <NewsDatatablePage />,
},
{
path: 'news/add',
element: (
<AuthRoute permission="news.update">
<CreateNewsArticlePage />
</AuthRoute>
),
},
{
path: 'news/:articleId/edit',
element: (
<AuthRoute permission="news.update">
<EditNewsArticlePage />
</AuthRoute>
),
},
// Comments
{
path: 'comments',
element: <CommentsDatatablePage />,
},
// Reviews
{
path: 'reviews',
element: <ReviewsDatatablePage />,
},
// Videos
{
path: 'videos',
element: <VideosDatatablePage />,
},
{
path: 'videos/new',
element: <CreateVideoPage />,
},
{
path: 'videos/:videoId/edit',
element: <EditVideoPage />,
},
// Title tags
{
path: 'keywords',
element: <TitleTagsDatatablePage type={KEYWORD_MODEL} />,
},
{
path: 'genres',
element: <TitleTagsDatatablePage type={GENRE_MODEL} />,
},
];

View File

@@ -0,0 +1,131 @@
import {
IAppearanceConfig,
MenuSectionConfig,
SeoSettingsSectionConfig,
} from '@common/admin/appearance/types/appearance-editor-config';
import {message} from '@common/i18n/message';
import {LandingPageSectionGeneral} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-general';
import {LandingPageSectionActionButtons} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-action-buttons';
import {LandingPageSectionPrimaryFeatures} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-primary-features';
import {LandingPageSecondaryFeatures} from '@app/admin/appearance/sections/landing-page-section/landing-page-section-secondary-features';
import {AppearanceEditorBreadcrumbItem} from '@common/admin/appearance/types/appearance-editor-section';
export const AppAppearanceConfig: IAppearanceConfig = {
preview: {
defaultRoute: 'dashboard',
navigationRoutes: ['dashboard'],
},
sections: {
'landing-page': {
label: message('Landing Page'),
position: 1,
previewRoute: '/',
routes: [
{path: 'landing-page', element: <LandingPageSectionGeneral />},
{
path: 'landing-page/action-buttons',
element: <LandingPageSectionActionButtons />,
},
{
path: 'landing-page/primary-features',
element: <LandingPageSectionPrimaryFeatures />,
},
{
path: 'landing-page/secondary-features',
element: <LandingPageSecondaryFeatures />,
},
],
buildBreadcrumb: pathname => {
const parts = pathname.split('/').filter(p => !!p);
const sectionName = parts.pop();
// admin/appearance
const breadcrumb: AppearanceEditorBreadcrumbItem[] = [
{
label: message('Landing page'),
location: 'landing-page',
},
];
if (sectionName === 'action-buttons') {
breadcrumb.push({
label: message('Action buttons'),
location: 'landing-page/action-buttons',
});
}
if (sectionName === 'primary-features') {
breadcrumb.push({
label: message('Primary features'),
location: 'landing-page/primary-features',
});
}
if (sectionName === 'secondary-features') {
breadcrumb.push({
label: message('Secondary features'),
location: 'landing-page/secondary-features',
});
}
return breadcrumb;
},
},
// missing label will get added by deepMerge from default config
// @ts-ignore
menus: {
config: {
positions: [
'sidebar-primary',
'sidebar-secondary',
'mobile-bottom',
'landing-page-navbar',
'landing-page-footer',
],
availableRoutes: [
'/lists',
'/watchlist',
'/admin/channels',
'/admin/comments',
],
} as MenuSectionConfig,
},
// @ts-ignore
'seo-settings': {
config: {
pages: [
{
key: 'title-page',
label: message('Title page'),
},
{
key: 'season-page',
label: message('Season page'),
},
{
key: 'episode-page',
label: message('Episode page'),
},
{
key: 'watch-page',
label: message('Watch page'),
},
{
key: 'person-page',
label: message('Person page'),
},
{
key: 'landing-page',
label: message('Landing page'),
},
{
key: 'news-article-page',
label: message('News article page'),
},
{
key: 'channel-page',
label: message('Channel page'),
},
],
} as SeoSettingsSectionConfig,
},
},
};

View File

@@ -0,0 +1,33 @@
import {MenuItemForm} from '@common/admin/menus/menu-item-form';
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {Trans} from '@common/i18n/trans';
import {appearanceState} from '@common/admin/appearance/appearance-store';
import {useState} from 'react';
export function LandingPageSectionActionButtons() {
const [expandedValues, setExpandedValues] = useState(['cta1']);
return (
<Accordion
variant="outline"
expandedValues={expandedValues}
onExpandedChange={values => {
setExpandedValues(values as string[]);
if (values.length) {
appearanceState().preview.setHighlight(
`[data-testid="${values[0]}"]`
);
}
}}
>
<AccordionItem value="cta1" label={<Trans message="Header button 1" />}>
<MenuItemForm formPathPrefix="settings.homepage.appearance.actions.cta1" />
</AccordionItem>
<AccordionItem value="ct2" label={<Trans message="Header button 2" />}>
<MenuItemForm formPathPrefix="settings.homepage.appearance.actions.cta2" />
</AccordionItem>
<AccordionItem value="cta3" label={<Trans message="Footer button" />}>
<MenuItemForm formPathPrefix="settings.homepage.appearance.actions.cta3" />
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,216 @@
import {useFormContext} from 'react-hook-form';
import {
appearanceState,
AppearanceValues,
useAppearanceStore,
} from '@common/admin/appearance/appearance-store';
import {Fragment, ReactNode} from 'react';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import {FormImageSelector} from '@common/ui/images/image-selector';
import {FormSlider} from '@common/ui/forms/slider/slider';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {AppearanceButton} from '@common/admin/appearance/appearance-button';
import {ColorIcon} from '@common/admin/appearance/sections/themes/color-icon';
import {ColorPickerDialog} from '@common/ui/color-picker/color-picker-dialog';
import {Link} from 'react-router-dom';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {LandingPageContent} from '@app/landing-page/landing-page-content';
export function LandingPageSectionGeneral() {
return (
<Fragment>
<HeaderSection />
<div className="my-24 border-y py-24">
<AppearanceButton
to="action-buttons"
elementType={Link}
className="mb-20"
>
<Trans message="Action buttons" />
</AppearanceButton>
<AppearanceButton to="primary-features" elementType={Link}>
<Trans message="Primary features" />
</AppearanceButton>
<AppearanceButton to="secondary-features" elementType={Link}>
<Trans message="Secondary features" />
</AppearanceButton>
</div>
<FooterSection />
<PricingSection />
</Fragment>
);
}
function HeaderSection() {
const defaultImage = useAppearanceStore(
s => s.defaults?.settings.homepage?.appearance?.headerImage,
);
return (
<Fragment>
<FormTextField
label={<Trans message="Header title" />}
className="mb-20"
name="settings.homepage.appearance.headerTitle"
onFocus={() => {
appearanceState().preview.setHighlight('[data-testid="headerTitle"]');
}}
/>
<FormTextField
label={<Trans message="Header subtitle" />}
className="mb-30"
inputElementType="textarea"
rows={4}
name="settings.homepage.appearance.headerSubtitle"
onFocus={() => {
appearanceState().preview.setHighlight(
'[data-testid="headerSubtitle"]',
);
}}
/>
<FormImageSelector
name="settings.homepage.appearance.headerImage"
className="mb-30"
label={<Trans message="Header image" />}
defaultValue={defaultImage}
diskPrefix="homepage"
/>
<FormSwitch
className="mb-24"
name="settings.homepage.appearance.blurHeaderImage"
>
<Trans message="Blur header image" />
</FormSwitch>
<FormSlider
name="settings.homepage.appearance.headerImageOpacity"
label={<Trans message="Header image opacity" />}
minValue={0}
step={0.1}
maxValue={1}
formatOptions={{style: 'percent'}}
/>
<div className="mb-20 text-xs text-muted">
<Trans message="In order for overlay colors to appear, header image opacity will need to be less then 100%" />
</div>
<ColorPickerTrigger
formKey="settings.homepage.appearance.headerOverlayColor1"
label={<Trans message="Header overlay color 1" />}
/>
<ColorPickerTrigger
formKey="settings.homepage.appearance.headerOverlayColor2"
label={<Trans message="Header overlay color 2" />}
/>
</Fragment>
);
}
function FooterSection() {
const defaultImage = useAppearanceStore(
s =>
(s.defaults?.settings.homepage?.appearance as LandingPageContent)
?.footerImage,
);
return (
<Fragment>
<FormSwitch className="mb-24" name="settings.homepage.trending">
<Trans message="Show trending titles" />
</FormSwitch>
<FormTextField
label={<Trans message="Footer title" />}
className="mb-20"
name="settings.homepage.appearance.footerTitle"
onFocus={() => {
appearanceState().preview.setHighlight('[data-testid="footerTitle"]');
}}
/>
<FormTextField
label={<Trans message="Footer subtitle" />}
className="mb-20"
name="settings.homepage.appearance.footerSubtitle"
onFocus={() => {
appearanceState().preview.setHighlight(
'[data-testid="footerSubtitle"]',
);
}}
/>
<FormImageSelector
name="settings.homepage.appearance.footerImage"
className="mb-30"
label={<Trans message="Footer background image" />}
defaultValue={defaultImage}
diskPrefix="homepage"
/>
</Fragment>
);
}
function PricingSection() {
return (
<div className="mt-24 border-t pt-24">
<FormTextField
label={<Trans message="Pricing title" />}
className="mb-20"
name="settings.homepage.appearance.pricingTitle"
onFocus={() => {
appearanceState().preview.setHighlight(
'[data-testid="pricingTitle"]',
);
}}
/>
<FormTextField
label={<Trans message="Pricing subtitle" />}
className="mb-20"
name="settings.homepage.appearance.pricingSubtitle"
onFocus={() => {
appearanceState().preview.setHighlight(
'[data-testid="pricingSubtitle"]',
);
}}
/>
<FormSwitch className="mb-24" name="settings.homepage.pricing">
<Trans message="Show pricing table" />
</FormSwitch>
</div>
);
}
interface ColorPickerTriggerProps {
formKey: string;
label: ReactNode;
}
function ColorPickerTrigger({label, formKey}: ColorPickerTriggerProps) {
const key = formKey as 'settings.homepage.appearance.headerOverlayColor1';
const {watch, setValue} = useFormContext<AppearanceValues>();
const formValue = watch(key);
const setColor = (value: string | null) => {
setValue(formKey as any, value, {
shouldDirty: true,
});
};
return (
<DialogTrigger
value={formValue}
onValueChange={newValue => setColor(newValue)}
type="popover"
onClose={value => setColor(value)}
>
<AppearanceButton
className="capitalize"
startIcon={
<ColorIcon
viewBox="0 0 48 48"
className="icon-lg"
style={{fill: formValue}}
/>
}
>
{label}
</AppearanceButton>
<ColorPickerDialog />
</DialogTrigger>
);
}

View File

@@ -0,0 +1,117 @@
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {Trans} from '@common/i18n/trans';
import {
appearanceState,
useAppearanceStore,
} from '@common/admin/appearance/appearance-store';
import {useFieldArray} from 'react-hook-form';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {FormImageSelector} from '@common/ui/images/image-selector';
import {Button} from '@common/ui/buttons/button';
import {AddIcon} from '@common/icons/material/Add';
import {useState} from 'react';
export function LandingPageSectionPrimaryFeatures() {
const {fields, remove, append} = useFieldArray({
name: 'settings.homepage.appearance.primaryFeatures',
});
const [expandedValues, setExpandedValues] = useState([0]);
return (
<div>
<Accordion
variant="outline"
expandedValues={expandedValues}
onExpandedChange={values => {
setExpandedValues(values as number[]);
if (values.length) {
appearanceState().preview.setHighlight(
`[data-testid="primary-root-${values[0]}"]`
);
}
}}
>
{fields.map((field, index) => {
return (
<AccordionItem
key={field.id}
value={index}
label={<Trans message={`Primary feature ${index + 1}`} />}
>
<FeatureForm index={index} />
<div className="text-right">
<Button
size="xs"
variant="outline"
color="danger"
onClick={() => {
remove(index);
}}
>
<Trans message="Remove" />
</Button>
</div>
</AccordionItem>
);
})}
</Accordion>
<div className="mt-20 text-right">
<Button
size="xs"
variant="outline"
color="primary"
startIcon={<AddIcon />}
onClick={() => {
append({});
setExpandedValues([fields.length]);
}}
>
<Trans message="Add feature" />
</Button>
</div>
</div>
);
}
interface FeatureFormProps {
index: number;
}
function FeatureForm({index}: FeatureFormProps) {
const defaultImage = useAppearanceStore(
s =>
s.defaults?.settings.homepage?.appearance?.primaryFeatures?.[index]?.image
);
return (
<>
<FormImageSelector
name={`settings.homepage.appearance.primaryFeatures.${index}.image`}
className="mb-30"
label={<Trans message="Image" />}
defaultValue={defaultImage}
diskPrefix="homepage"
/>
<FormTextField
name={`settings.homepage.appearance.primaryFeatures.${index}.title`}
label={<Trans message="Title" />}
className="mb-20"
onFocus={() => {
appearanceState().preview.setHighlight(
`[data-testid="primary-title-${index}"]`
);
}}
/>
<FormTextField
name={`settings.homepage.appearance.primaryFeatures.${index}.subtitle`}
label={<Trans message="Subtitle" />}
className="mb-20"
inputElementType="textarea"
rows={4}
onFocus={() => {
appearanceState().preview.setHighlight(
`[data-testid="primary-subtitle-${index}"]`
);
}}
/>
</>
);
}

View File

@@ -0,0 +1,126 @@
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {Trans} from '@common/i18n/trans';
import {appearanceState} from '@common/admin/appearance/appearance-store';
import {useFieldArray} from 'react-hook-form';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {FormImageSelector} from '@common/ui/images/image-selector';
import {Button} from '@common/ui/buttons/button';
import {AddIcon} from '@common/icons/material/Add';
import {useState} from 'react';
export function LandingPageSecondaryFeatures() {
const {fields, remove, append} = useFieldArray({
name: 'settings.homepage.appearance.secondaryFeatures',
});
const [expandedValues, setExpandedValues] = useState([0]);
return (
<div>
<Accordion
variant="outline"
expandedValues={expandedValues}
onExpandedChange={values => {
setExpandedValues(values as number[]);
if (values.length) {
appearanceState().preview.setHighlight(
`[data-testid="secondary-root-${values[0]}"]`
);
}
}}
>
{fields.map((field, index) => {
return (
<AccordionItem
key={field.id}
value={index}
label={<Trans message={`Secondary feature ${index + 1}`} />}
>
<FeatureForm index={index} />
<div className="text-right">
<Button
size="xs"
variant="outline"
color="danger"
onClick={() => {
remove(index);
}}
>
<Trans message="Remove" />
</Button>
</div>
</AccordionItem>
);
})}
</Accordion>
<div className="mt-20 text-right">
<Button
size="xs"
variant="outline"
color="primary"
startIcon={<AddIcon />}
onClick={() => {
append({});
setExpandedValues([fields.length]);
}}
>
<Trans message="Add feature" />
</Button>
</div>
</div>
);
}
interface FeatureFormProps {
index: number;
}
function FeatureForm({index}: FeatureFormProps) {
return (
<>
<FormImageSelector
name={`settings.homepage.appearance.secondaryFeatures.${index}.image`}
className="mb-30"
label={<Trans message="Image" />}
defaultValue={getDefaultImage(index)}
diskPrefix="homepage"
/>
<FormTextField
name={`settings.homepage.appearance.secondaryFeatures.${index}.title`}
label={<Trans message="Title" />}
className="mb-20"
onFocus={() => {
appearanceState().preview.setHighlight(
`[data-testid="secondary-title-${index}"]`
);
}}
/>
<FormTextField
name={`settings.homepage.appearance.secondaryFeatures.${index}.subtitle`}
label={<Trans message="Subtitle" />}
className="mb-20"
inputElementType="textarea"
rows={4}
onFocus={() => {
appearanceState().preview.setHighlight(
`[data-testid="secondary-subtitle-${index}"]`
);
}}
/>
<FormTextField
name={`settings.homepage.appearance.secondaryFeatures.${index}.description`}
label={<Trans message="Description" />}
className="mb-20"
inputElementType="textarea"
rows={4}
onFocus={() => {
appearanceState().preview.setHighlight(
`[data-testid="secondary-description-${index}"]`
);
}}
/>
</>
);
}
function getDefaultImage(index: number): string | undefined {
return appearanceState().defaults?.settings.homepage?.appearance
.secondaryFeatures[index]?.image;
}

View File

@@ -0,0 +1,38 @@
import {useSettings} from '@common/core/settings/use-settings';
import {useFormContext} from 'react-hook-form';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
import {ContentAutoUpdateField} from '@common/admin/channels/channel-editor/controls/content-auto-update-field';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {Trans} from '@common/i18n/trans';
import React from 'react';
interface Props {
className?: string;
}
export function ChannelAutoUpdateField({className}: Props) {
const {tmdb_is_setup} = useSettings();
const {watch} = useFormContext<UpdateChannelPayload>();
const methodConfig =
channelContentConfig.autoUpdateMethods[watch('config.autoUpdateMethod')!];
return (
<ContentAutoUpdateField config={channelContentConfig} className={className}>
{!methodConfig?.provider && tmdb_is_setup && (
<FormSelect
selectionMode="single"
className="mt-24 flex-auto md:mt-0"
name="config.autoUpdateProvider"
label={<Trans message="Fetch content from" />}
required
>
<Option value="tmdb">
<Trans message="TheMovieDB" />
</Option>
<Option value="local">
<Trans message="Local database" />
</Option>
</FormSelect>
)}
</ContentAutoUpdateField>
);
}

View File

@@ -0,0 +1,254 @@
import {message} from '@common/i18n/message';
import {
MOVIE_MODEL,
SERIES_MODEL,
Title,
TITLE_MODEL,
} from '@app/titles/models/title';
import {NEWS_ARTICLE_MODEL, NewsArticle} from '@app/titles/models/news-article';
import {Channel, CHANNEL_MODEL} from '@common/channels/channel';
import {ChannelContentConfig} from '@common/admin/channels/channel-editor/channel-content-config';
import {Person, PERSON_MODEL} from '@app/titles/models/person';
import {GridViewIcon} from '@common/icons/material/GridView';
import {ViewWeekIcon} from '@common/icons/material/ViewWeek';
import {ViewListIcon} from '@common/icons/material/ViewList';
export enum Sort {
popular = 'popularity:desc',
recent = 'created_at:desc',
rating = 'rating:desc',
curated = 'channelables.order:asc',
name = 'name:asc',
birthdayDesc = 'birth_date:desc',
birthdayAsc = 'birth_date:asc',
budget = 'budget:desc',
revenue = 'revenue:desc',
}
export enum Layout {
grid = 'grid',
landscapeGrid = 'landscapeGrid',
list = 'list',
news = 'news',
carousel = 'carousel',
landscapeCarousel = 'landscapeCarousel',
slider = 'slider',
}
enum Auto {
latestVideos = 'latestVideos',
mostPopular = 'mostPopular',
topRated = 'topRated',
upcoming = 'upcoming',
nowPlaying = 'nowPlaying',
airingToday = 'airingToday',
airingThisWeek = 'airingThisWeek',
trendingPeople = 'trendingPeople',
discover = 'discover',
}
const contentModels: ChannelContentConfig['models'] = {
[MOVIE_MODEL]: {
label: message('Movies'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.rating,
Sort.budget,
Sort.revenue,
],
layoutMethods: [
Layout.grid,
Layout.landscapeGrid,
Layout.list,
Layout.carousel,
Layout.landscapeCarousel,
Layout.slider,
],
autoUpdateMethods: [
Auto.latestVideos,
Auto.mostPopular,
Auto.topRated,
Auto.upcoming,
Auto.nowPlaying,
Auto.discover,
],
},
[SERIES_MODEL]: {
label: message('TV series'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.rating,
Sort.budget,
Sort.revenue,
],
layoutMethods: [
Layout.grid,
Layout.landscapeGrid,
Layout.list,
Layout.carousel,
Layout.landscapeCarousel,
Layout.slider,
],
autoUpdateMethods: [
Auto.latestVideos,
Auto.mostPopular,
Auto.topRated,
Auto.airingThisWeek,
Auto.airingToday,
Auto.discover,
],
},
[TITLE_MODEL]: {
label: message('Titles (movies and series)'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.rating,
Sort.budget,
Sort.revenue,
],
layoutMethods: [
Layout.grid,
Layout.landscapeGrid,
Layout.list,
Layout.carousel,
Layout.landscapeCarousel,
Layout.slider,
],
autoUpdateMethods: [Auto.latestVideos],
},
[NEWS_ARTICLE_MODEL]: {
label: message('News articles'),
sortMethods: [Sort.recent],
layoutMethods: [Layout.news, Layout.landscapeCarousel, Layout.list],
},
[PERSON_MODEL]: {
label: message('People'),
sortMethods: [
Sort.popular,
Sort.recent,
Sort.name,
Sort.birthdayDesc,
Sort.birthdayAsc,
],
layoutMethods: [Layout.grid, Layout.list, Layout.carousel],
autoUpdateMethods: [Auto.trendingPeople],
},
[CHANNEL_MODEL]: {
label: message('Channels'),
sortMethods: [],
layoutMethods: [Layout.list],
},
};
const contentSortingMethods: Record<
Sort,
ChannelContentConfig['sortingMethods']['any']
> = {
[Sort.popular]: {
label: message('Most popular first'),
},
[Sort.recent]: {
label: message('Recently added first'),
},
[Sort.rating]: {
label: message('Highest rated first'),
},
[Sort.curated]: {
label: message('Curated (reorder below)'),
contentTypes: ['manual'],
},
[Sort.name]: {
label: message('Name (A-Z)'),
contentTypes: ['manual'],
},
[Sort.birthdayDesc]: {
label: message('Youngest first'),
},
[Sort.birthdayAsc]: {
label: message('Oldest first'),
},
[Sort.budget]: {
label: message('Biggest budget first'),
},
[Sort.revenue]: {
label: message('Biggest revenue first'),
},
};
const contentLayoutMethods: Record<
Layout,
ChannelContentConfig['layoutMethods']['any']
> = {
[Layout.grid]: {
label: message('Grid'),
icon: <GridViewIcon />,
},
[Layout.landscapeGrid]: {
label: message('Landscape'),
icon: <ViewWeekIcon />,
},
[Layout.list]: {
label: message('List'),
icon: <ViewListIcon />,
},
[Layout.carousel]: {
label: message('Carousel (portrait)'),
},
[Layout.landscapeCarousel]: {
label: message('Carousel (landscape)'),
},
[Layout.slider]: {
label: message('Slider'),
},
[Layout.news]: {
label: message('News'),
},
};
const contentAutoUpdateMethods: Record<
Auto,
ChannelContentConfig['autoUpdateMethods']['any']
> = {
[Auto.discover]: {
label: message('Discover (TMDB only)'),
provider: 'tmdb',
},
[Auto.mostPopular]: {
label: message('Most popular'),
},
[Auto.topRated]: {
label: message('Top rated'),
},
[Auto.upcoming]: {
label: message('Upcoming'),
},
[Auto.nowPlaying]: {
label: message('In theaters'),
},
[Auto.airingToday]: {
label: message('Airing today'),
},
[Auto.airingThisWeek]: {
label: message('Airing this week'),
},
[Auto.trendingPeople]: {
label: message('Trending people'),
},
[Auto.latestVideos]: {
label: message('Most recently published videos'),
provider: 'local',
},
};
export const channelContentConfig: ChannelContentConfig = {
models: contentModels,
sortingMethods: contentSortingMethods,
layoutMethods: contentLayoutMethods,
autoUpdateMethods: contentAutoUpdateMethods,
userSelectableLayouts: [Layout.grid, Layout.landscapeGrid, Layout.list],
};
export type ChannelContentModel = (Title | NewsArticle | Person | Channel) & {
channelable_id?: number;
channelable_order?: number;
};

View File

@@ -0,0 +1,24 @@
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {useImageSrc} from '@app/images/use-image-src';
import clsx from 'clsx';
import {ImageIcon} from '@common/icons/material/Image';
interface Props {
item: NormalizedModel;
}
export function ChannelContentItemImage({item}: Props) {
const src = useImageSrc(item.image, {size: 'sm'});
const imageClassName = clsx(
'aspect-square w-40 rounded object-cover',
!src ? 'flex items-center justify-center' : 'block',
);
return src ? (
<img className={imageClassName} src={src} alt="" />
) : (
<span className={imageClassName}>
<ImageIcon className="max-w-[60%] text-divider" size="text-6xl" />
</span>
);
}

View File

@@ -0,0 +1,140 @@
import {Trans} from '@common/i18n/trans';
import {Item} from '@common/ui/forms/listbox/item';
import {FormSelect} from '@common/ui/forms/select/select';
import React, {Fragment, useState} from 'react';
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 {useValueLists} from '@common/http/value-lists';
import {message} from '@common/i18n/message';
import {useTrans} from '@common/i18n/use-trans';
import {useFormContext} from 'react-hook-form';
import {UpdateChannelPayload} from '@common/admin/channels/requests/use-update-channel';
import {MOVIE_MODEL, SERIES_MODEL, TITLE_MODEL} from '@app/titles/models/title';
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
import clsx from 'clsx';
import {ChannelsDocsLink} from '@common/admin/channels/channels-docs-link';
const supportedModels = [TITLE_MODEL, MOVIE_MODEL, SERIES_MODEL];
const restrictions = {
[GENRE_MODEL]: message('Genre'),
[KEYWORD_MODEL]: message('Keyword'),
[PRODUCTION_COUNTRY_MODEL]: message('Production country'),
};
interface Props {
className?: string;
}
export function ChannelRestrictionField({className}: Props) {
const {setValue} = useFormContext<UpdateChannelPayload>();
const {watch} = useFormContext<UpdateChannelPayload>();
if (!supportedModels.includes(watch('config.contentModel'))) {
return null;
}
return (
<div className={clsx('items-end gap-14 md:flex', className)}>
<FormSelect
className="w-full flex-auto"
name="config.restriction"
selectionMode="single"
label={
<Fragment>
<Trans message="Filter titles by" />
<InfoTrigger />
</Fragment>
}
onSelectionChange={() => {
setValue('config.restrictionModelId', 'urlParam');
}}
>
<Item value={null}>
<Trans message="Don't filter titles" />
</Item>
{Object.entries(restrictions).map(([value, label]) => (
<Item key={value} value={value}>
<Trans {...label} />
</Item>
))}
</FormSelect>
<RestrictionModelField />
</div>
);
}
function RestrictionModelField() {
const {trans} = useTrans();
const [searchValue, setSearchValue] = useState('');
const {watch} = useFormContext<UpdateChannelPayload>();
const {data} = useValueLists(['genres', 'productionCountries'], {
type: watch('config.autoUpdateProvider'),
});
const selectedRestriction = watch(
'config.restriction',
) as keyof typeof restrictions;
const selectedKeywordId = watch('config.restrictionModelId');
const keywordQuery = useValueLists(['keywords'], {
searchQuery: searchValue,
selectedValue: selectedKeywordId,
type: watch('config.autoUpdateProvider'),
});
if (!selectedRestriction) return null;
const options = {
[GENRE_MODEL]: data?.genres,
[KEYWORD_MODEL]: keywordQuery.data?.keywords,
[PRODUCTION_COUNTRY_MODEL]: data?.productionCountries,
};
const restrictionLabel = restrictions[selectedRestriction];
// allow setting keyword to custom value, because there are too many keywords
// to put into autocomplete, ideally it would use async search from backend though
return (
<FormSelect
className="w-full flex-auto"
name="config.restrictionModelId"
selectionMode="single"
showSearchField
searchPlaceholder={trans(message('Search...'))}
isAsync={selectedRestriction === KEYWORD_MODEL}
isLoading={
selectedRestriction === KEYWORD_MODEL && keywordQuery.isLoading
}
inputValue={searchValue}
onInputValueChange={setSearchValue}
label={
<Trans
message=":restriction name"
values={{restriction: trans(restrictionLabel)}}
/>
}
>
<Item value="urlParam">
<Trans message="Dynamic (from url)" />
</Item>
{options[selectedRestriction]?.map(option => (
<Item key={option.value} value={option.value}>
<Trans message={option.name} />
</Item>
))}
</FormSelect>
);
}
function InfoTrigger() {
return (
<InfoDialogTrigger
body={
<Fragment>
<Trans message="Allows specifying additional condition channel content should be filtered on. " />
<ChannelsDocsLink className="mt-20" hash="filter-titles-by" />
</Fragment>
}
/>
);
}

View File

@@ -0,0 +1,26 @@
import React, {Fragment} from 'react';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {Trans} from '@common/i18n/trans';
import {useTrans} from '@common/i18n/use-trans';
import {message} from '@common/i18n/message';
export function ChannelSeoFields() {
const {trans} = useTrans();
return (
<Fragment>
<FormTextField
name="config.seoTitle"
label={<Trans message="SEO title" />}
className="mb-24"
placeholder={trans(message('Optional'))}
/>
<FormTextField
name="config.seoDescription"
label={<Trans message="SEO description" />}
inputElementType="textarea"
rows={6}
placeholder={trans(message('Optional'))}
/>
</Fragment>
);
}

View File

@@ -0,0 +1,98 @@
import React, {ReactElement} from 'react';
import {CreateChannelPageLayout} from '@common/admin/channels/channel-editor/create-channel-page-layout';
import {MOVIE_MODEL} from '@app/titles/models/title';
import {Trans} from '@common/i18n/trans';
import {ChannelNameField} from '@common/admin/channels/channel-editor/controls/channel-name-field';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {ContentTypeField} from '@common/admin/channels/channel-editor/controls/content-type-field';
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
import {ContentModelField} from '@common/admin/channels/channel-editor/controls/content-model-field';
import {ChannelRestrictionField} from '@app/admin/channels/channel-restriction-field';
import {ContentOrderField} from '@common/admin/channels/channel-editor/controls/content-order-field';
import {ContentLayoutFields} from '@common/admin/channels/channel-editor/controls/content-layout-fields';
import {ChannelPaginationTypeField} from '@common/admin/channels/channel-editor/controls/channel-pagination-type-field';
import {ChannelAutoUpdateField} from '@app/admin/channels/channel-auto-update-field';
import {ChannelSeoFields} from '@app/admin/channels/channel-seo-fields';
import clsx from 'clsx';
import {Tabs} from '@common/ui/tabs/tabs';
import {TabList} from '@common/ui/tabs/tab-list';
import {Tab} from '@common/ui/tabs/tab';
import {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';
export function CreateChannelPage() {
return (
<CreateChannelPageLayout
defaultValues={{
contentModel: MOVIE_MODEL,
autoUpdateProvider: 'local',
layout: 'grid',
nestedLayout: 'carousel',
paginationType: 'infiniteScroll',
}}
>
<Tabs>
<TabList>
<Tab>
<Trans message="Settings" />
</Tab>
<Tab>
<Trans message="SEO" />
</Tab>
</TabList>
<TabPanels className="pt-24">
<TabPanel>
<ChannelNameField />
<FormSwitch
className="mt-24"
name="config.hideTitle"
description={
<Trans message="Whether title should be shown when displaying this channel on the site." />
}
>
<Trans message="Hide title" />
</FormSwitch>
<FormTextField
name="description"
label={<Trans message="Description" />}
inputElementType="textarea"
rows={2}
className="my-24"
/>
<ContentTypeField config={channelContentConfig} className="mb-24" />
<ChannelAutoUpdateField className="mb-24" />
<ContentModelField
config={channelContentConfig}
className="mb-24"
/>
<ChannelRestrictionField className="mb-24" />
<ContentOrderField config={channelContentConfig} />
<ContentLayoutFields
config={channelContentConfig}
className="my-24"
/>
<ChannelPaginationTypeField
config={channelContentConfig}
className="mb-24"
/>
</TabPanel>
<TabPanel>
<ChannelSeoFields />
</TabPanel>
</TabPanels>
</Tabs>
</CreateChannelPageLayout>
);
}
interface TitleProps {
children: ReactElement;
className?: string;
}
function Title({children, className}: TitleProps) {
return (
<h2 className={clsx('mb-20 mt-20 border-t pt-20 text-2xl', className)}>
{children}
</h2>
);
}

View File

@@ -0,0 +1,115 @@
import {EditChannelPageLayout} from '@common/admin/channels/channel-editor/edit-channel-page-layout';
import React, {Fragment} from 'react';
import {Accordion, AccordionItem} from '@common/ui/accordion/accordion';
import {Trans} from '@common/i18n/trans';
import {DescriptionIcon} from '@common/icons/material/Description';
import {ChannelNameField} from '@common/admin/channels/channel-editor/controls/channel-name-field';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
import {SettingsIcon} from '@common/icons/material/Settings';
import {ContentTypeField} from '@common/admin/channels/channel-editor/controls/content-type-field';
import {channelContentConfig} from '@app/admin/channels/channel-content-config';
import {ChannelAutoUpdateField} from '@app/admin/channels/channel-auto-update-field';
import {ContentModelField} from '@common/admin/channels/channel-editor/controls/content-model-field';
import {ChannelRestrictionField} from '@app/admin/channels/channel-restriction-field';
import {ContentOrderField} from '@common/admin/channels/channel-editor/controls/content-order-field';
import {DashboardIcon} from '@common/icons/material/Dashboard';
import {ContentLayoutFields} from '@common/admin/channels/channel-editor/controls/content-layout-fields';
import {ChannelPaginationTypeField} from '@common/admin/channels/channel-editor/controls/channel-pagination-type-field';
import {PublicIcon} from '@common/icons/material/Public';
import {ChannelSeoFields} from '@app/admin/channels/channel-seo-fields';
import {ChannelContentEditor} from '@common/admin/channels/channel-editor/channel-content-editor';
import {
ChannelContentSearchField,
ChannelContentSearchFieldProps,
} from '@common/admin/channels/channel-editor/channel-content-search-field';
import {ChannelContentItemImage} from '@app/admin/channels/channel-content-item-image';
export function EditChannelPage() {
return (
<EditChannelPageLayout>
<Fragment>
<Accordion variant="outline">
<AccordionItem
label={<Trans message="Title & description" />}
startIcon={<DescriptionIcon />}
>
<ChannelNameField />
<FormSwitch
className="mt-24"
name="config.hideTitle"
description={
<Trans message="Whether title should be shown when displaying this channel on the site." />
}
>
<Trans message="Hide title" />
</FormSwitch>
<FormTextField
name="description"
label={<Trans message="Description" />}
inputElementType="textarea"
rows={1}
className="mt-24"
/>
<FormTextField
name="config.adminDescription"
label={
<Fragment>
<Trans message="Internal description" />
<InfoDialogTrigger
body={
<Trans message="This describes the purpose of the channel and is only visible in admin area." />
}
/>
</Fragment>
}
inputElementType="textarea"
rows={1}
className="mt-24"
/>
</AccordionItem>
<AccordionItem
label={<Trans message="Content settings" />}
startIcon={<SettingsIcon />}
>
<ContentTypeField config={channelContentConfig} className="mb-24" />
<ChannelAutoUpdateField className="mb-24" />
<ContentModelField
config={channelContentConfig}
className="mb-24"
/>
<ChannelRestrictionField className="mb-24" />
<ContentOrderField config={channelContentConfig} />
</AccordionItem>
<AccordionItem
label={<Trans message="Layout" />}
startIcon={<DashboardIcon />}
>
<ContentLayoutFields
config={channelContentConfig}
className="mb-24"
/>
<ChannelPaginationTypeField config={channelContentConfig} />
</AccordionItem>
<AccordionItem
label={<Trans message="SEO" />}
startIcon={<PublicIcon />}
>
<ChannelSeoFields />
</AccordionItem>
</Accordion>
<ChannelContentEditor searchField={<SearchField />} />
</Fragment>
</EditChannelPageLayout>
);
}
function SearchField(props: ChannelContentSearchFieldProps) {
return (
<ChannelContentSearchField
{...props}
imgRenderer={item => <ChannelContentItemImage item={item} />}
/>
);
}

View File

@@ -0,0 +1,113 @@
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 {NameWithAvatar} from '@common/datatable/column-templates/name-with-avatar';
import {CheckIcon} from '@common/icons/material/Check';
import {CloseIcon} from '@common/icons/material/Close';
import {Channel} from '@common/channels/channel';
import {FormattedNumber} from '@common/i18n/formatted-number';
export const ListsDatatableColumns: ColumnConfig<Channel>[] = [
{
key: 'name',
allowsSorting: true,
width: 'flex-3',
visibleInMode: 'all',
header: () => <Trans message="Name" />,
body: list => {
return (
<a
className="outline-none hover:underline focus-visible:underline"
href={`lists/${list.id}`}
target="_blank"
rel="noreferrer"
>
{list.name}
</a>
);
},
},
{
key: 'user_id',
allowsSorting: true,
width: 'flex-2 min-w-140',
header: () => <Trans message="Owner" />,
body: list =>
list.user && (
<NameWithAvatar
image={list.user.avatar}
label={list.user.display_name}
description={list.user.email}
/>
),
},
{
key: 'items_count',
width: 'w-96',
header: () => <Trans message="Items" />,
body: list =>
list.items_count && <FormattedNumber value={list.items_count} />,
},
{
key: 'public',
header: () => <Trans message="Public" />,
width: 'w-96',
body: list =>
list.public ? (
<CheckIcon className="text-positive" />
) : (
<CloseIcon className="text-danger" />
),
},
{
key: 'content_type',
allowsSorting: false,
header: () => <Trans message="Content type" />,
body: list => (
<span className="capitalize">
{list.config.contentModel ? (
<Trans message={list.config.contentModel} />
) : undefined}
</span>
),
},
{
key: 'layout',
allowsSorting: false,
header: () => <Trans message="Layout" />,
body: list => (
<span className="capitalize">
{list.config.layout ? (
<Trans message={list.config.layout} />
) : undefined}
</span>
),
},
{
key: 'updated_at',
allowsSorting: true,
maxWidth: 'max-w-100',
header: () => <Trans message="Last updated" />,
body: list =>
list.updated_at ? <FormattedDate date={list.updated_at} /> : '',
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
visibleInMode: 'all',
align: 'end',
width: 'w-42 flex-shrink-0',
body: list => (
<Link to={`${list.id}/edit`} className="text-muted">
<IconButton size="md">
<EditIcon />
</IconButton>
</Link>
),
},
];

View File

@@ -0,0 +1,36 @@
import React 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 todoImage from './todo.svg';
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
import {Link} from 'react-router-dom';
import {ListsDatatableColumns} from '@app/admin/lists/lists-datatable-columns';
export function ListsDatatablePage() {
return (
<DataTablePage
endpoint="lists"
title={<Trans message="User lists" />}
columns={ListsDatatableColumns}
actions={<Actions />}
selectedActions={<DeleteSelectedItemsAction />}
emptyStateMessage={
<DataTableEmptyStateMessage
image={todoImage}
title={<Trans message="No lists have been created yet" />}
filteringTitle={<Trans message="No matching lists" />}
/>
}
/>
);
}
function Actions() {
return (
<DataTableAddItemButton elementType={Link} to="new">
<Trans message="Add new list" />
</DataTableAddItemButton>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -0,0 +1,66 @@
import React, {Suspense} from 'react';
import {FormProvider, useForm} from 'react-hook-form';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {ArticleEditorTitle} from '@common/article-editor/article-editor-title';
import {ArticleEditorStickyHeader} from '@common/article-editor/article-editor-sticky-header';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
import {
CreateNewsArticlePayload,
useCreatNewsArticle,
} from '@app/admin/news/requests/use-create-news-article';
import {FormImageSelector} from '@common/ui/images/image-selector';
const ArticleBodyEditor = React.lazy(
() => import('@common/article-editor/article-body-editor'),
);
export function CreateNewsArticlePage() {
const navigate = useNavigate();
const createArticle = useCreatNewsArticle();
const form = useForm<CreateNewsArticlePayload>({});
const handleSave = (editorContent: string) => {
createArticle.mutate(
{
...form.getValues(),
body: editorContent,
},
{
onSuccess: () => navigate('..', {relative: 'path'}),
},
);
};
return (
<Suspense fallback={<FullPageLoader />}>
<ArticleBodyEditor>
{(content, editor) => (
<FileUploadProvider>
<FormProvider {...form}>
<ArticleEditorStickyHeader
editor={editor}
backLink=".."
isLoading={createArticle.isPending}
onSave={handleSave}
/>
<div className="mx-20">
<FormImageSelector
className="mx-auto mb-32 max-w-[655px]"
showEditButtonOnHover
variant="square"
name="image"
diskPrefix="news_images"
/>
<div className="prose mx-auto flex-auto dark:prose-invert">
<ArticleEditorTitle />
{content}
</div>
</div>
</FormProvider>
</FileUploadProvider>
)}
</ArticleBodyEditor>
</Suspense>
);
}

View File

@@ -0,0 +1,94 @@
import React, {Fragment, Suspense} from 'react';
import {PageMetaTags} from '@common/http/page-meta-tags';
import {PageStatus} from '@common/http/page-status';
import {FormProvider, useForm} from 'react-hook-form';
import {FileUploadProvider} from '@common/uploads/uploader/file-upload-provider';
import {ArticleEditorTitle} from '@common/article-editor/article-editor-title';
import {ArticleEditorStickyHeader} from '@common/article-editor/article-editor-sticky-header';
import {useNavigate} from '@common/utils/hooks/use-navigate';
import {FullPageLoader} from '@common/ui/progress/full-page-loader';
import {useNewsArticle} from '@app/admin/news/requests/use-news-article';
import {NewsArticle} from '@app/titles/models/news-article';
import {useUpdateNewsArticle} from '@app/admin/news/requests/use-update-news-article';
import {CreateNewsArticlePayload} from '@app/admin/news/requests/use-create-news-article';
import {FormImageSelector} from '@common/ui/images/image-selector';
const ArticleBodyEditor = React.lazy(
() => import('@common/article-editor/article-body-editor'),
);
export function EditNewsArticlePage() {
const query = useNewsArticle('newsArticlePage');
return query.data ? (
<Fragment>
<PageMetaTags query={query} />
<PageContent article={query.data.article} />
</Fragment>
) : (
<div className="relative h-full w-full">
<PageStatus query={query} />
</div>
);
}
interface PageContentProps {
article: NewsArticle;
}
function PageContent({article}: PageContentProps) {
const navigate = useNavigate();
const updateArticle = useUpdateNewsArticle();
const form = useForm<CreateNewsArticlePayload>({
defaultValues: {
title: article.title,
slug: article.slug,
body: article.body,
image: article.image,
},
});
const handleSave = (editorContent: string) => {
updateArticle.mutate(
{
...form.getValues(),
body: editorContent,
},
{
onSuccess: () => navigate('../..', {relative: 'path'}),
},
);
};
return (
<Suspense fallback={<FullPageLoader />}>
<ArticleBodyEditor initialContent={article.body}>
{(content, editor) => (
<FileUploadProvider>
<FormProvider {...form}>
<ArticleEditorStickyHeader
editor={editor}
backLink="../.."
slugPrefix="news"
isLoading={updateArticle.isPending}
onSave={handleSave}
/>
<div className="mx-20">
<FormImageSelector
className="mx-auto mb-32 max-w-[655px]"
showEditButtonOnHover
variant="square"
name="image"
diskPrefix="news_images"
/>
<div className="prose mx-auto flex-auto dark:prose-invert">
<ArticleEditorTitle />
{content}
</div>
</div>
</FormProvider>
</FileUploadProvider>
)}
</ArticleBodyEditor>
</Suspense>
);
}

View File

@@ -0,0 +1,117 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {NewsArticle} from '@app/titles/models/news-article';
import {Trans} from '@common/i18n/trans';
import {FormattedDate} from '@common/i18n/formatted-date';
import {Link} from 'react-router-dom';
import {Tooltip} from '@common/ui/tooltip/tooltip';
import {IconButton} from '@common/ui/buttons/icon-button';
import {EditIcon} from '@common/icons/material/Edit';
import {useContext} from 'react';
import {TableContext} from '@common/ui/tables/table-context';
import clsx from 'clsx';
import {useDeleteNewsArticle} from '@app/admin/news/requests/use-delete-news-article';
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 {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {NewsArticleLink} from '@app/news/news-article-link';
import {NewsArticleImage} from '@app/news/news-article-image';
export const newsDatatableColumns: ColumnConfig<NewsArticle>[] = [
{
key: 'name',
width: 'flex-3 min-w-200',
visibleInMode: 'all',
header: () => <Trans message="Title" />,
body: article => <ArticleColumn article={article} />,
},
{
key: 'updatedAt',
allowsSorting: true,
width: 'w-96',
header: () => <Trans message="Last updated" />,
body: article => (
<time>
<FormattedDate date={article.updated_at} />
</time>
),
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
width: 'w-84 flex-shrink-0',
hideHeader: true,
align: 'end',
visibleInMode: 'all',
body: article => (
<div className="text-muted">
<Link to={`${article.id}/edit`}>
<Tooltip label={<Trans message="Edit article" />}>
<IconButton size="md">
<EditIcon />
</IconButton>
</Tooltip>
</Link>
<DialogTrigger type="modal">
<Tooltip label={<Trans message="Delete article" />}>
<IconButton>
<DeleteIcon />
</IconButton>
</Tooltip>
<DeleteArticleDialog article={article} />
</DialogTrigger>
</div>
),
},
];
interface ArticleColumnProps {
article: NewsArticle;
}
function ArticleColumn({article}: ArticleColumnProps) {
const {isCollapsedMode} = useContext(TableContext);
return (
<div className="flex gap-14">
<NewsArticleImage article={article} size="w-52 h-52" lazy={false} />
<div className="min-w-0">
<div
className={clsx(
isCollapsedMode
? 'whitespace-normal'
: 'font-medium whitespace-nowrap overflow-hidden overflow-ellipsis',
)}
>
<NewsArticleLink article={article} target="_blank" />
</div>
{!isCollapsedMode && (
<p className="text-muted mt-4 text-xs max-w-680 whitespace-normal">
{article.body}
</p>
)}
</div>
</div>
);
}
interface DeleteArticleDialogProps {
article: NewsArticle;
}
export function DeleteArticleDialog({article}: DeleteArticleDialogProps) {
const deleteArticle = useDeleteNewsArticle();
const {close} = useDialogContext();
return (
<ConfirmationDialog
isDanger
isLoading={deleteArticle.isPending}
title={<Trans message="Delete article" />}
body={<Trans message="Are you sure you want to delete this article?" />}
confirm={<Trans message="Delete" />}
onConfirm={() => {
deleteArticle.mutate(
{articleId: article.id},
{onSuccess: () => close()},
);
}}
/>
);
}

View File

@@ -0,0 +1,15 @@
import {BackendFilter} from '@common/datatable/filters/backend-filter';
import {message} from '@common/i18n/message';
import {
createdAtFilter,
updatedAtFilter,
} from '@common/datatable/filters/timestamp-filters';
export const NewsDatatableFilters: BackendFilter[] = [
createdAtFilter({
description: message('Date article was created'),
}),
updatedAtFilter({
description: message('Date article was last updated'),
}),
];

View File

@@ -0,0 +1,62 @@
import {Fragment} from 'react';
import {Trans} from '@common/i18n/trans';
import {Link} from 'react-router-dom';
import {DataTablePage} from '@common/datatable/page/data-table-page';
import {NewsDatatableFilters} from '@app/admin/news/news-datatable-filters';
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
import onlineArticlesImg from '@app/admin/news/online-articles.svg';
import {newsDatatableColumns} from '@app/admin/news/news-datatable-columns';
import {IconButton} from '@common/ui/buttons/icon-button';
import {PublishIcon} from '@common/icons/material/Publish';
import {useImportNewsArticles} from '@app/admin/news/requests/use-import-news-articles';
import {Tooltip} from '@common/ui/tooltip/tooltip';
export function NewsDatatablePage() {
return (
<DataTablePage
endpoint="news"
title={<Trans message="News articles" />}
filters={NewsDatatableFilters}
columns={newsDatatableColumns}
queryParams={{
stripHtml: 'true',
truncateBody: 200,
}}
actions={<Actions />}
selectedActions={<DeleteSelectedItemsAction />}
enableSelection={false}
cellHeight="h-80"
emptyStateMessage={
<DataTableEmptyStateMessage
image={onlineArticlesImg}
title={<Trans message="No articles have been created yet" />}
filteringTitle={<Trans message="No matching articles" />}
/>
}
/>
);
}
function Actions() {
const importArticles = useImportNewsArticles();
return (
<Fragment>
<Tooltip label={<Trans message="Import news articles" />}>
<IconButton
variant="outline"
color="primary"
size="sm"
onClick={() => importArticles.mutate()}
disabled={importArticles.isPending}
>
<PublishIcon />
</IconButton>
</Tooltip>
<DataTableAddItemButton elementType={Link} to="add">
<Trans message="Add news article" />
</DataTableAddItemButton>
</Fragment>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,33 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '@common/http/backend-response/backend-response';
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 {NewsArticle} from '@app/titles/models/news-article';
interface Response extends BackendResponse {
article: NewsArticle;
}
export interface CreateNewsArticlePayload {
title?: string;
body?: string;
slug?: string;
image?: string;
}
export function useCreatNewsArticle() {
return useMutation({
mutationFn: (payload: CreateNewsArticlePayload) => createArticle(payload),
onError: err => showHttpErrorToast(err),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['news']});
toast(message('Article created'));
},
});
}
function createArticle(payload: CreateNewsArticlePayload): Promise<Response> {
return apiClient.post(`news`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,30 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '@common/http/backend-response/backend-response';
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 {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
interface Response extends BackendResponse {}
interface Payload {
articleId: number;
}
export function useDeleteNewsArticle() {
return useMutation({
mutationFn: (payload: Payload) => deleteArticle(payload),
onError: err => showHttpErrorToast(err),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('news'),
});
toast(message('Article deleted'));
},
});
}
function deleteArticle(payload: Payload): Promise<Response> {
return apiClient.delete(`news/${payload.articleId}`).then(r => r.data);
}

View File

@@ -0,0 +1,23 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {toast} from '@common/ui/toast/toast';
import {apiClient, queryClient} from '@common/http/query-client';
import {message} from '@common/i18n/message';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
interface Response extends BackendResponse {}
export function useImportNewsArticles() {
return useMutation({
mutationFn: () => importArticles(),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['news']});
toast(message('Imported news articles'));
},
onError: r => showHttpErrorToast(r),
});
}
function importArticles(): Promise<Response> {
return apiClient.post(`news/import-from-remote-provider`).then(r => r.data);
}

View File

@@ -0,0 +1,32 @@
import {useQuery} from '@tanstack/react-query';
import {apiClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {NewsArticle} from '@app/titles/models/news-article';
import {useParams} from 'react-router-dom';
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
export interface GetNewsArticleResponse extends BackendResponse {
article: NewsArticle;
related: NewsArticle[];
}
export function useNewsArticle(loader: 'newsArticlePage') {
const {articleId} = useParams();
return useQuery<GetNewsArticleResponse>({
queryKey: ['news-articles', `${articleId}`],
queryFn: () => fetchNewsArticle(articleId!),
initialData: () => {
const data = getBootstrapData().loaders?.[loader];
if (data?.article?.id == articleId) {
return data;
}
return undefined;
},
});
}
function fetchNewsArticle(articleId: string) {
return apiClient
.get<GetNewsArticleResponse>(`news/${articleId}`)
.then(response => response.data);
}

View File

@@ -0,0 +1,33 @@
import {useMutation} from '@tanstack/react-query';
import {BackendResponse} from '@common/http/backend-response/backend-response';
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 {NewsArticle} from '@app/titles/models/news-article';
import {CreateNewsArticlePayload} from '@app/admin/news/requests/use-create-news-article';
import {useParams} from 'react-router-dom';
interface Response extends BackendResponse {
article: NewsArticle;
}
export function useUpdateNewsArticle() {
const {articleId} = useParams();
return useMutation({
mutationFn: (payload: CreateNewsArticlePayload) =>
updateArticle(articleId!, payload),
onError: err => showHttpErrorToast(err),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['news']});
toast(message('Article updated'));
},
});
}
function updateArticle(
articleId: string,
payload: CreateNewsArticlePayload,
): Promise<Response> {
return apiClient.put(`news/${articleId}`, payload).then(r => r.data);
}

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

View File

@@ -0,0 +1,39 @@
import React from 'react';
import {useOutletContext} from 'react-router-dom';
import {AdminReportOutletContext} from '@app/admin/reports/mtdb-admin-report-page';
import {InsightsReportRow} from '@app/admin/reports/insights/insights-report-row';
import {InsightsPlaysChart} from '@app/admin/reports/insights/insights-plays-chart';
import {InsightsDevicesChart} from '@app/admin/reports/insights/insights-devices-chart';
import {InsightsSeriesChart} from '@app/admin/reports/insights/insights-series-chart';
import {InsightsMoviesChart} from '@app/admin/reports/insights/insights-movies-chart';
import {InsightsVideosChart} from '@app/admin/reports/insights/insights-videos-chart';
import {InsightsUsersChart} from '@app/admin/reports/insights/insights-users-chart';
import {InsightsLocationsChart} from '@app/admin/reports/insights/insights-locations-chart';
import {InsightsPlatformsChart} from '@app/admin/reports/insights/insights-platforms-chart';
import {InsightsChartsContext} from '@app/admin/reports/insights/insights-charts-context';
export function AdminInsightsReport() {
const {dateRange} = useOutletContext<AdminReportOutletContext>();
const model = 'video_play=0';
return (
<InsightsChartsContext.Provider value={{dateRange, model}}>
<InsightsReportRow>
<InsightsPlaysChart />
<InsightsDevicesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsSeriesChart />
<InsightsMoviesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsVideosChart />
<InsightsUsersChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsLocationsChart />
<InsightsPlatformsChart />
</InsightsReportRow>
</InsightsChartsContext.Provider>
);
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import {useOutletContext} from 'react-router-dom';
import {VisitorsReportCharts} from '@common/admin/analytics/visitors-report-charts';
import {useAdminReport} from '@common/admin/analytics/use-admin-report';
import {AdminReportOutletContext} from '@app/admin/reports/mtdb-admin-report-page';
export function AdminVisitorsReport() {
const {dateRange} = useOutletContext<AdminReportOutletContext>();
const {data, isLoading, isPlaceholderData} = useAdminReport({
types: ['visitors'],
dateRange: dateRange,
});
return (
<VisitorsReportCharts
isLoading={isLoading || isPlaceholderData}
report={data?.visitorsReport}
/>
);
}

View File

@@ -0,0 +1,53 @@
import {cloneElement, ReactElement, useCallback, useRef, useState} from 'react';
import {BaseChartProps} from '@common/charts/base-chart';
import {UseQueryResult} from '@tanstack/react-query';
import {
FetchInsightsReportResponse,
InsightsReportMetric,
useInsightsReport,
} from '@app/admin/reports/requests/use-insights-report';
import {useInsightsChartContext} from '@app/admin/reports/insights/insights-charts-context';
interface Props {
children:
| ReactElement<BaseChartProps>
| ((
query: UseQueryResult<FetchInsightsReportResponse>
) => ReactElement<BaseChartProps>);
metric: InsightsReportMetric;
}
export function InsightsAsyncChart({children, metric}: Props) {
const [isEnabled, setIsEnabled] = useState(false);
const {dateRange, model} = useInsightsChartContext();
const query = useInsightsReport(
{metrics: [metric], model, dateRange},
{isEnabled}
);
const chart = typeof children === 'function' ? children(query) : children;
const observerRef = useRef<IntersectionObserver>();
const contentRef = useCallback((el: HTMLDivElement | null) => {
if (el) {
const observer = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) {
setIsEnabled(true);
observerRef.current?.disconnect();
observerRef.current = undefined;
}
},
{threshold: 0.1} // if only header is visible, don't load
);
observerRef.current = observer;
observer.observe(el);
} else if (observerRef.current) {
observerRef.current?.disconnect();
}
}, []);
return cloneElement<BaseChartProps>(chart, {
data: query.data?.report?.[metric],
isLoading: query.isLoading,
contentRef,
});
}

View File

@@ -0,0 +1,14 @@
import React, {useContext} from 'react';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
export interface InsightsChartsContextValue {
dateRange: DateRangeValue;
model: string;
}
export const InsightsChartsContext =
React.createContext<InsightsChartsContextValue>(null!);
export function useInsightsChartContext() {
return useContext(InsightsChartsContext);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {PolarAreaChart} from '@common/charts/polar-area-chart';
export function InsightsDevicesChart() {
return (
<InsightsAsyncChart metric="devices">
<PolarAreaChart title={<Trans message="Top devices" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {TopModelsChartLayout} from '@app/admin/reports/top-models-chart-layout';
export function InsightsEpisodesChart() {
return (
<InsightsAsyncChart metric="episodes">
<TopModelsChartLayout title={<Trans message="Most played episodes" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {GeoChart} from '@common/admin/analytics/geo-chart/geo-chart';
export function InsightsLocationsChart() {
return (
<InsightsAsyncChart metric="locations">
<GeoChart className="flex-auto w-1/2 lg:max-w-[740px]" />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {TopModelsChartLayout} from '@app/admin/reports/top-models-chart-layout';
export function InsightsMoviesChart() {
return (
<InsightsAsyncChart metric="movies">
<TopModelsChartLayout title={<Trans message="Most played movies" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,15 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {PolarAreaChart} from '@common/charts/polar-area-chart';
export function InsightsPlatformsChart() {
return (
<InsightsAsyncChart metric="platforms">
<PolarAreaChart
className="max-w-500"
title={<Trans message="Top platforms" />}
/>
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,29 @@
import {LineChart} from '@common/charts/line-chart';
import {Trans} from '@common/i18n/trans';
import {FormattedNumber} from '@common/i18n/formatted-number';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
export function InsightsPlaysChart() {
return (
<InsightsAsyncChart metric="plays">
{({data}) => (
<LineChart
className="flex-auto"
title={<Trans message="Plays" />}
hideLegend
description={
<Trans
message=":count total plays"
values={{
count: (
<FormattedNumber value={data?.report.plays.total || 0} />
),
}}
/>
}
/>
)}
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,12 @@
import {ReactNode} from 'react';
interface Props {
children: ReactNode;
}
export function InsightsReportRow({children}: Props) {
return (
<div className="mb-12 flex flex-col gap-12 overflow-x-auto md:mb-18 md:gap-18 lg:flex-row lg:items-center">
{children}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {TopModelsChartLayout} from '@app/admin/reports/top-models-chart-layout';
export function InsightsSeasonsChart() {
return (
<InsightsAsyncChart metric="seasons">
<TopModelsChartLayout title={<Trans message="Most played seasons" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {TopModelsChartLayout} from '@app/admin/reports/top-models-chart-layout';
export function InsightsSeriesChart() {
return (
<InsightsAsyncChart metric="series">
<TopModelsChartLayout title={<Trans message="Most played series" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {TopModelsChartLayout} from '@app/admin/reports/top-models-chart-layout';
export function InsightsUsersChart() {
return (
<InsightsAsyncChart metric="users">
<TopModelsChartLayout title={<Trans message="Top users" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,12 @@
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {InsightsAsyncChart} from '@app/admin/reports/insights/insights-async-chart';
import {TopModelsChartLayout} from '@app/admin/reports/top-models-chart-layout';
export function InsightsVideosChart() {
return (
<InsightsAsyncChart metric="videos">
<TopModelsChartLayout title={<Trans message="Most played videos" />} />
</InsightsAsyncChart>
);
}

View File

@@ -0,0 +1,96 @@
import React, {
cloneElement,
Fragment,
ReactElement,
ReactNode,
useState,
} from 'react';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
import {ReportDateSelector} from '@common/admin/analytics/report-date-selector';
import {Trans} from '@common/i18n/trans';
import {InsightsChartsContext} from '@app/admin/reports/insights/insights-charts-context';
import {IconButton} from '@common/ui/buttons/icon-button';
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
import {Link} from 'react-router-dom';
import {StaticPageTitle} from '@common/seo/static-page-title';
interface Props {
children: ReactNode;
reportModel: string;
name: string;
backLink?: string;
title?: ReactElement;
}
export function ModelInsightsPageLayout({
children,
reportModel,
title,
name,
backLink,
}: Props) {
const [dateRange, setDateRange] = useState<DateRangeValue>(() => {
// This week
return DateRangePresets[2].getRangeValue();
});
return (
<Fragment>
<StaticPageTitle>
<Trans message=":name insights" values={{name}} />
</StaticPageTitle>
<div className="h-full flex flex-col">
<div className="flex-auto bg-cover relative">
<div className="min-h-full p-12 md:p-24 overflow-x-hidden max-w-[1600px] mx-auto flex flex-col">
<div className="flex-auto">
<div className="md:flex items-center gap-12 h-48 mt-14 mb-38">
<IconButton
elementType={Link}
to={backLink || '../../'}
relative="path"
className="text-muted"
>
<ArrowBackIcon />
</IconButton>
{title}
<div className="ml-auto flex-shrink-0 flex items-center justify-between gap-10 md:gap-24">
<ReportDateSelector
value={dateRange}
onChange={setDateRange}
/>
</div>
</div>
<InsightsChartsContext.Provider
value={{dateRange, model: reportModel}}
>
{children}
</InsightsChartsContext.Provider>
</div>
</div>
</div>
</div>
</Fragment>
);
}
interface ModelInsightsPageTitleProps {
image: ReactElement<{size: string; className: string}>;
name: ReactElement;
description?: ReactElement;
}
export function ModelInsightsPageTitle({
image,
name,
description,
}: ModelInsightsPageTitleProps) {
return (
<div className="flex items-center gap-10">
{cloneElement(image, {size: 'w-48 h-48', className: 'rounded'})}
<div>
<h1 className="text-base whitespace-nowrap overflow-hidden overflow-ellipsis">
{name} <Trans message="insights" />
</h1>
{description && <div className="text-muted text-sm">{description}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import {Trans} from '@common/i18n/trans';
import {Link, Outlet, useParams} from 'react-router-dom';
import React, {useState} from 'react';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
import {ReportDateSelector} from '@common/admin/analytics/report-date-selector';
import {Button} from '@common/ui/buttons/button';
import {ButtonGroup} from '@common/ui/buttons/button-group';
import {AdminHeaderReport} from '@common/admin/analytics/admin-header-report';
import {useAdminReport} from '@common/admin/analytics/use-admin-report';
import {StaticPageTitle} from '@common/seo/static-page-title';
export interface AdminReportOutletContext {
dateRange: DateRangeValue;
setDateRange: (dateRange: DateRangeValue) => void;
}
export function MtdbAdminReportPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>(() => {
// This week
return DateRangePresets[2].getRangeValue();
});
const params = useParams();
const channel = params['*'] || 'plays';
const title =
channel === 'visitors' ? (
<Trans message="Visitors report" />
) : (
<Trans message="Plays report" />
);
return (
<div className="min-h-full overflow-x-hidden p-12 md:p-24">
<div className="mb-24 items-center justify-between gap-24 md:flex">
<StaticPageTitle>{title}</StaticPageTitle>
<h1 className="mb-24 text-3xl font-light md:mb-0">{title}</h1>
<div className="flex flex-shrink-0 items-center justify-between gap-10 md:gap-24">
<ButtonGroup variant="outline" value={channel}>
<Button value="plays" elementType={Link} to="plays">
<Trans message="Plays" />
</Button>
<Button value="visitors" elementType={Link} to="visitors">
<Trans message="Visitors" />
</Button>
</ButtonGroup>
<ReportDateSelector value={dateRange} onChange={setDateRange} />
</div>
</div>
<Header dateRange={dateRange} />
<Outlet context={{dateRange, setDateRange}} />
</div>
);
}
interface HeaderProps {
dateRange: DateRangeValue;
}
function Header({dateRange}: HeaderProps) {
const {data} = useAdminReport({types: ['header'], dateRange});
return <AdminHeaderReport report={data?.headerReport} />;
}

View File

@@ -0,0 +1,62 @@
import React from 'react';
import {PageStatus} from '@common/http/page-status';
import {
ModelInsightsPageLayout,
ModelInsightsPageTitle,
} from '@app/admin/reports/model-insights-page-layout';
import {TitleLinkWithEpisodeNumber} from '@app/titles/title-link';
import {InsightsReportRow} from '@app/admin/reports/insights/insights-report-row';
import {InsightsPlaysChart} from '@app/admin/reports/insights/insights-plays-chart';
import {InsightsDevicesChart} from '@app/admin/reports/insights/insights-devices-chart';
import {InsightsLocationsChart} from '@app/admin/reports/insights/insights-locations-chart';
import {InsightsPlatformsChart} from '@app/admin/reports/insights/insights-platforms-chart';
import {useEpisode} from '@app/episodes/requests/use-episode';
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
import {EpisodeLink} from '@app/episodes/episode-link';
export function EpisodeInsightsPage() {
const query = useEpisode('episode');
return query.data ? (
<ModelInsightsPageLayout
reportModel={`episode=${query.data.episode.id}`}
name={query.data.episode.name}
backLink="../../../../"
title={
<ModelInsightsPageTitle
image={
<EpisodePoster
episode={query.data.episode}
title={query.data.title}
srcSize="sm"
/>
}
name={
<EpisodeLink
episode={query.data.episode}
title={query.data.title}
seasonNumber={query.data.episode.season_number}
/>
}
description={
<TitleLinkWithEpisodeNumber
episode={query.data.episode}
title={query.data.title}
/>
}
/>
}
>
<InsightsReportRow>
<InsightsPlaysChart />
<InsightsDevicesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsLocationsChart />
<InsightsPlatformsChart />
</InsightsReportRow>
</ModelInsightsPageLayout>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
import {PageStatus} from '@common/http/page-status';
import {
ModelInsightsPageLayout,
ModelInsightsPageTitle,
} from '@app/admin/reports/model-insights-page-layout';
import {TitleLink} from '@app/titles/title-link';
import {InsightsReportRow} from '@app/admin/reports/insights/insights-report-row';
import {InsightsPlaysChart} from '@app/admin/reports/insights/insights-plays-chart';
import {InsightsDevicesChart} from '@app/admin/reports/insights/insights-devices-chart';
import {InsightsLocationsChart} from '@app/admin/reports/insights/insights-locations-chart';
import {InsightsPlatformsChart} from '@app/admin/reports/insights/insights-platforms-chart';
import {useSeason} from '@app/seasons/requests/use-season';
import {SeasonPoster} from '@app/seasons/season-poster';
import {SeasonLink} from '@app/seasons/season-link';
export function SeasonInsightsPage() {
const query = useSeason('season');
return query.data ? (
<ModelInsightsPageLayout
reportModel={`season=${query.data.season.id}`}
name={`Season ${query.data.season.number}`}
title={
<ModelInsightsPageTitle
image={
<SeasonPoster
season={query.data.season}
title={query.data.title}
srcSize="sm"
/>
}
name={
<SeasonLink
seasonNumber={query.data.season.number}
title={query.data.title}
/>
}
description={<TitleLink title={query.data.title} />}
/>
}
>
<InsightsReportRow>
<InsightsPlaysChart />
<InsightsDevicesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsLocationsChart />
<InsightsPlatformsChart />
</InsightsReportRow>
</ModelInsightsPageLayout>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import {useTitle} from '@app/titles/requests/use-title';
import {PageStatus} from '@common/http/page-status';
import {
ModelInsightsPageLayout,
ModelInsightsPageTitle,
} from '@app/admin/reports/model-insights-page-layout';
import {useParams} from 'react-router-dom';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {TitleLink} from '@app/titles/title-link';
import {InsightsReportRow} from '@app/admin/reports/insights/insights-report-row';
import {InsightsPlaysChart} from '@app/admin/reports/insights/insights-plays-chart';
import {InsightsDevicesChart} from '@app/admin/reports/insights/insights-devices-chart';
import {InsightsLocationsChart} from '@app/admin/reports/insights/insights-locations-chart';
import {InsightsPlatformsChart} from '@app/admin/reports/insights/insights-platforms-chart';
import {InsightsSeasonsChart} from '@app/admin/reports/insights/insights-seasons-chart';
import {InsightsEpisodesChart} from '@app/admin/reports/insights/insights-episodes-chart';
export function TitleInsightsPage() {
const {titleId} = useParams();
const query = useTitle('title');
return query.data ? (
<ModelInsightsPageLayout
reportModel={`title=${titleId}`}
name={query.data.title.name}
title={
<ModelInsightsPageTitle
image={<TitlePoster title={query.data.title} srcSize="sm" />}
name={<TitleLink title={query.data.title} />}
description={<span>{query.data.title.year}</span>}
/>
}
>
<InsightsReportRow>
<InsightsPlaysChart />
<InsightsDevicesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsSeasonsChart />
<InsightsEpisodesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsLocationsChart />
<InsightsPlatformsChart />
</InsightsReportRow>
</ModelInsightsPageLayout>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import {PageStatus} from '@common/http/page-status';
import {
ModelInsightsPageLayout,
ModelInsightsPageTitle,
} from '@app/admin/reports/model-insights-page-layout';
import {TitleLink} from '@app/titles/title-link';
import {InsightsReportRow} from '@app/admin/reports/insights/insights-report-row';
import {InsightsPlaysChart} from '@app/admin/reports/insights/insights-plays-chart';
import {InsightsDevicesChart} from '@app/admin/reports/insights/insights-devices-chart';
import {InsightsLocationsChart} from '@app/admin/reports/insights/insights-locations-chart';
import {InsightsPlatformsChart} from '@app/admin/reports/insights/insights-platforms-chart';
import {useVideo} from '@app/admin/videos/requests/use-video';
import {Link} from 'react-router-dom';
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
import {VideoThumbnail} from '@app/videos/video-thumbnail';
export function VideoInsightsPage() {
const query = useVideo();
const video = query.data?.video;
return video ? (
<ModelInsightsPageLayout
reportModel={`video=${video.id}`}
name={video.name}
title={
<ModelInsightsPageTitle
image={<VideoThumbnail video={video} srcSize="sm" />}
name={
<Link
to={getWatchLink(video)}
className="hover:underline"
target="_blank"
>
{video.name}
</Link>
}
description={<TitleLink title={video.title!} />}
/>
}
>
<InsightsReportRow>
<InsightsPlaysChart />
<InsightsDevicesChart />
</InsightsReportRow>
<InsightsReportRow>
<InsightsLocationsChart />
<InsightsPlatformsChart />
</InsightsReportRow>
</ModelInsightsPageLayout>
) : (
<PageStatus query={query} loaderClassName="absolute inset-0 m-auto" />
);
}

View File

@@ -0,0 +1,86 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
import {apiClient} from '@common/http/query-client';
import {
DatasetItem,
LocationDatasetItem,
ReportMetric,
} from '@common/admin/analytics/report-metric';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {User} from '@common/auth/user';
import {Title} from '@app/titles/models/title';
import {Video} from '@app/titles/models/video';
import {Episode} from '@app/titles/models/episode';
import {Season} from '@app/titles/models/season';
const endpoint = 'reports/insights';
export interface TopModelDatasetItem extends DatasetItem {
model: Title | Season | Episode | Video | User;
}
export interface FetchInsightsReportResponse extends BackendResponse {
report: {
totalClicks: number;
plays: ReportMetric;
browsers: ReportMetric;
locations: ReportMetric<LocationDatasetItem>;
devices: ReportMetric;
platforms: ReportMetric;
movies: ReportMetric<TopModelDatasetItem>;
series: ReportMetric<TopModelDatasetItem>;
titles: ReportMetric<TopModelDatasetItem>;
videos: ReportMetric<TopModelDatasetItem>;
users: ReportMetric<TopModelDatasetItem>;
seasons: ReportMetric<TopModelDatasetItem>;
episodes: ReportMetric<TopModelDatasetItem>;
};
}
export type InsightsReportMetric =
| 'plays'
| 'devices'
| 'browsers'
| 'platforms'
| 'locations'
| 'movies'
| 'series'
| 'titles'
| 'seasons'
| 'episodes'
| 'users'
| 'videos';
interface Payload {
dateRange: DateRangeValue;
model?: string;
metrics?: InsightsReportMetric[];
}
interface Options {
isEnabled: boolean;
}
export function useInsightsReport(payload: Payload, options: Options) {
return useQuery({
queryKey: [endpoint, payload],
queryFn: () => fetchReport(endpoint, payload),
placeholderData: keepPreviousData,
enabled: options.isEnabled,
staleTime: Infinity,
});
}
function fetchReport<
T extends FetchInsightsReportResponse = FetchInsightsReportResponse,
>(endpoint: string, payload: Payload): Promise<T> {
const params: Record<string, any> = {
model: payload.model,
metrics: payload.metrics?.join(','),
};
params.startDate = payload.dateRange.start.toAbsoluteString();
params.endDate = payload.dateRange.end.toAbsoluteString();
params.timezone = payload.dateRange.start.timeZone;
return apiClient.get(endpoint, {params}).then(response => response.data);
}

View File

@@ -0,0 +1,196 @@
import {Trans} from '@common/i18n/trans';
import {ChartLayout, ChartLayoutProps} from '@common/charts/chart-layout';
import React, {Fragment, ReactElement} from 'react';
import {ReportMetric} from '@common/admin/analytics/report-metric';
import {ChartLoadingIndicator} from '@common/charts/chart-loading-indicator';
import {TopModelDatasetItem} from '@app/admin/reports/requests/use-insights-report';
import {InfoIcon} from '@common/icons/material/Info';
import {FormattedNumber} from '@common/i18n/formatted-number';
import {Link} from 'react-router-dom';
import {UserAvatar} from '@common/ui/images/user-avatar';
import {TitlePoster} from '@app/titles/title-poster/title-poster';
import {TitleLink} from '@app/titles/title-link';
import {UserProfileLink} from '@common/users/user-profile-link';
import {MediaPlayIcon} from '@common/icons/media/media-play';
import {getWatchLink} from '@app/videos/watch-page/get-watch-link';
import {SeasonPoster} from '@app/seasons/season-poster';
import {SeasonLink} from '@app/seasons/season-link';
import {EpisodePoster} from '@app/episodes/episode-poster/episode-poster';
import {EpisodeLink} from '@app/episodes/episode-link';
import clsx from 'clsx';
interface Props extends Partial<ChartLayoutProps> {
data?: ReportMetric<TopModelDatasetItem>;
title: ReactElement;
}
export function TopModelsChartLayout({data, isLoading, ...layoutProps}: Props) {
const dataItems = data?.datasets[0].data || [];
return (
<ChartLayout
{...layoutProps}
className="w-1/2 min-w-500 md:min-w-0"
contentIsFlex={isLoading}
contentClassName="max-h-[370px] overflow-y-auto compact-scrollbar"
>
{isLoading && <ChartLoadingIndicator />}
{dataItems.map(item => (
<div
key={item.model.id}
className="mb-20 flex items-center justify-between gap-24 text-sm"
>
<div className="flex items-center gap-8">
<Image
model={item.model}
size="w-42 h-42"
className="flex-shrink-0 rounded"
/>
<div>
<div className="text-sm">
<Name model={item.model} />
</div>
<div className="text-xs text-muted">
<Description model={item.model} />
</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-4">
<MediaPlayIcon className="text-muted" size="sm" />
<Trans
message=":count plays"
values={{count: <FormattedNumber value={item.value} />}}
/>
</div>
</div>
))}
{!isLoading && !dataItems.length ? (
<div className="flex items-center gap-8 text-muted">
<InfoIcon size="sm" />
<Trans message="No plays in selected timeframe." />
</div>
) : null}
</ChartLayout>
);
}
interface ImageProps {
model: TopModelDatasetItem['model'];
size: string;
className: string;
}
function Image({model, size, className}: ImageProps) {
const link = `/admin/${model.model_type}s/${model.id}`;
switch (model.model_type) {
case 'title':
return (
<TitlePoster
title={model}
size={size}
srcSize="sm"
className={className}
link={`/admin/titles/${model.id}/insights`}
/>
);
case 'season':
return (
<SeasonPoster
season={model}
title={model.title!}
size={size}
srcSize="sm"
className={className}
link={`/admin/titles/${model.title_id}/insights/seasons/${model.number}`}
/>
);
case 'episode':
return (
<EpisodePoster
episode={model}
title={model.title!}
size={size}
srcSize="sm"
className={className}
link={`/admin/titles/${model.title_id}/insights/seasons/${model.season_number}/episodes/${model.episode_number}`}
/>
);
case 'video':
return model.thumbnail ? (
<Link to={link} className={clsx(size, className)}>
<img src={model.thumbnail} className="h-full w-full" alt="" />
</Link>
) : (
<TitlePoster
title={model.title!}
size={size}
srcSize="sm"
className={className}
link={`/admin/videos/${model.id}/insights`}
/>
);
case 'user':
// there's no separate insights page for user
return <UserAvatar user={model} size={size} className={className} />;
}
}
interface NameProps {
model: TopModelDatasetItem['model'];
}
function Name({model}: NameProps) {
switch (model.model_type) {
case 'title':
return <TitleLink title={model} target="_blank" />;
case 'season':
return (
<SeasonLink
title={model.title!}
seasonNumber={model.number}
target="_blank"
/>
);
case 'episode':
return (
<EpisodeLink
title={model.title!}
episode={model}
seasonNumber={model.season_number}
target="_blank"
/>
);
case 'video':
return (
<Link
to={getWatchLink(model)}
className="hover:underline"
target="_blank"
>
{model.name}
</Link>
);
case 'user':
return model.id ? (
<UserProfileLink user={model} target="_blank" />
) : (
<Fragment>{model.display_name}</Fragment>
);
}
}
interface DescriptionProps {
model: TopModelDatasetItem['model'];
}
function Description({model}: DescriptionProps) {
switch (model.model_type) {
case 'title':
return <span>{model.year}</span>;
case 'season':
return <TitleLink title={model.title!} target="_blank" />;
case 'episode':
return <TitleLink title={model.title!} target="_blank" />;
case 'user':
return null;
case 'video':
return <TitleLink title={model.title!} target="_blank" />;
}
}

View File

@@ -0,0 +1,58 @@
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {ConfirmationDialog} from '@common/ui/overlays/dialog/confirmation-dialog';
import React from 'react';
import {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';
import {ButtonSize} from '@common/ui/buttons/button-size';
import {useDeleteReviews} from '@app/reviews/requests/use-delete-reviews';
interface Props {
reviewIds: number[];
variant?: ButtonVariant;
size?: ButtonSize;
}
export function DeleteReviewsButton({
reviewIds,
variant = 'outline',
size = 'xs',
}: Props) {
const deleteReviews = useDeleteReviews();
return (
<DialogTrigger
type="modal"
onClose={isConfirmed => {
if (isConfirmed) {
deleteReviews.mutate({reviewIds});
}
}}
>
<Button
variant={variant}
size={size}
color="danger"
className="mr-10"
disabled={deleteReviews.isPending}
>
<Trans message="Delete" />
</Button>
<ConfirmationDialog
isDanger
title={
<Trans
message="Delete [one review|other :count reviews]"
values={{count: reviewIds.length}}
/>
}
body={
reviewIds.length > 1 ? (
<Trans message="Are you sure you want to delete selected reviews?" />
) : (
<Trans message="Are you sure you want to delete this review?" />
)
}
confirm={<Trans message="Delete" />}
/>
</DialogTrigger>
);
}

View File

@@ -0,0 +1,41 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {Review} from '@app/titles/models/review';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {CreateReviewPayload} from '@app/reviews/requests/use-create-review';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {
review: Review;
}
export function useUpdateReview(
review: Review,
form?: UseFormReturn<CreateReviewPayload>,
) {
return useMutation({
mutationFn: (payload: CreateReviewPayload) => updateReview(review, payload),
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['reviews']});
toast(message('Review updated'));
},
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
});
}
function updateReview(
review: Review,
payload: CreateReviewPayload,
): Promise<Response> {
return apiClient
.put(`reviews/${review.id}`, {
score: payload.score,
title: payload.title,
body: payload.body,
})
.then(r => r.data);
}

View File

@@ -0,0 +1,41 @@
import {queryClient} from '@common/http/query-client';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import React from 'react';
import {ButtonVariant} from '@common/ui/buttons/get-shared-button-style';
import {ButtonSize} from '@common/ui/buttons/button-size';
import {useRestoreComments} from '@common/comments/requests/use-restore-comments';
interface Props {
commentIds: number[];
variant?: ButtonVariant;
size?: ButtonSize;
}
export function RestoreCommentsButton({
commentIds,
variant = 'outline',
size = 'xs',
}: Props) {
const restoreComments = useRestoreComments();
return (
<Button
variant={variant}
size={size}
className="mr-10"
disabled={restoreComments.isPending}
color="primary"
onClick={() => {
restoreComments.mutate(
{commentIds},
{
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['comment']});
},
},
);
}}
>
<Trans message="Restore" />
</Button>
);
}

View File

@@ -0,0 +1,246 @@
import {User} from '@common/auth/user';
import React, {Fragment, useContext, useState} from 'react';
import {Checkbox} from '@common/ui/forms/toggle/checkbox';
import {UserAvatar} from '@common/ui/images/user-avatar';
import {FormattedRelativeTime} from '@common/i18n/formatted-relative-time';
import {queryClient} from '@common/http/query-client';
import {Button} from '@common/ui/buttons/button';
import {Trans} from '@common/i18n/trans';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {SiteConfigContext} from '@common/core/settings/site-config-context';
import {Link} from 'react-router-dom';
import {LinkStyle} from '@common/ui/buttons/external-link';
import {NormalizedModel} from '@common/datatable/filters/normalized-model';
import {Review} from '@app/titles/models/review';
import {TitleRating} from '@app/reviews/title-rating';
import {useUpdateReview} from '@app/admin/reviews/requests/use-update-review';
import {useForm} from 'react-hook-form';
import {CreateReviewPayload} from '@app/reviews/requests/use-create-review';
import {Form} from '@common/ui/forms/form';
import {StarSelector} from '@app/reviews/review-list/star-selector';
import {DeleteReviewsButton} from '@app/admin/reviews/delete-reviews-button';
import {BulletSeparatedItems} from '@app/titles/bullet-separated-items';
interface Props {
review: Review;
isSelected: boolean;
onToggle: () => void;
}
export function ReviewDatatableItem({review, isSelected, onToggle}: Props) {
const [isEditing, setIsEditing] = useState(false);
const helpfulCount = review.helpful_count || 1;
const totalFeedbackCount =
review.helpful_count + review.not_helpful_count || 1;
return (
<div className="border-b p-14">
{review.reviewable && (
<ReviewableHeader
isSelected={isSelected}
onToggle={onToggle}
reviewable={review.reviewable}
/>
)}
<div className="flex items-start gap-10 pt-14 md:pl-20">
<UserAvatar className="flex-shrink-0" user={review.user} size="md" />
<div className="min-w-0 flex-auto overflow-hidden">
<ReviewHeader review={review} />
{isEditing ? (
<EditReviewForm
review={review}
onClose={isSaved => {
setIsEditing(false);
if (isSaved) {
queryClient.invalidateQueries({queryKey: ['comment']});
}
}}
/>
) : (
<Fragment>
<div className="my-14">
<TitleRating className="mb-8" score={review.score} />
{review.title && (
<div className="mb-8 text-base font-medium">
{review.title}
</div>
)}
<div className="whitespace-break-spaces text-sm">
{review.body}
</div>
<div className="mt-8 text-xs text-muted">
<BulletSeparatedItems>
<Trans
message=":helpfulCount out of :total people found this helpful"
values={{helpfulCount, total: totalFeedbackCount}}
/>
{review.reports_count ? (
<Trans
message=":count reports"
values={{count: review.reports_count || 0}}
/>
) : null}
</BulletSeparatedItems>
</div>
</div>
<div>
<DeleteReviewsButton reviewIds={[review.id]} />
<Button
variant="outline"
size="xs"
onClick={() => setIsEditing(true)}
>
<Trans message="Edit" />
</Button>
</div>
</Fragment>
)}
</div>
</div>
</div>
);
}
interface ReviewableHeaderProps {
isSelected: boolean;
onToggle: Props['onToggle'];
reviewable: NormalizedModel;
}
function ReviewableHeader({
isSelected,
onToggle,
reviewable,
}: ReviewableHeaderProps) {
return (
<div className="flex items-center">
<div className="mr-14">
<Checkbox checked={isSelected} onChange={() => onToggle()} />
</div>
{reviewable.image && (
<img
className="mr-6 h-20 w-20 overflow-hidden rounded object-cover"
src={reviewable.image}
alt=""
/>
)}
<div className="mr-4 text-sm">{reviewable.name}</div>
<div className="text-xs text-muted">({reviewable.model_type})</div>
</div>
);
}
interface CommentHeaderProps {
review: Review;
}
function ReviewHeader({review}: CommentHeaderProps) {
return (
<div className="flex items-center gap-4 text-sm">
<div>
{review.user && (
<UserDisplayName user={review.user} show="display_name" />
)}
</div>
<div>&bull;</div>
<time>
<FormattedRelativeTime date={review.created_at} />
</time>
{review.user && (
<div className="ml-auto hidden md:block">
{<UserDisplayName user={review.user} show="email" />}
</div>
)}
</div>
);
}
interface EditReviewFormProps {
review: Review;
onClose: (saved: boolean) => void;
}
function EditReviewForm({review, onClose}: EditReviewFormProps) {
const [content, setContent] = useState(review.body);
const updateReview = useUpdateReview(review);
const form = useForm<CreateReviewPayload>({
defaultValues: {
score: review.score,
title: review.title,
body: review.body,
},
});
return (
<Form
className="mt-24"
form={form}
onSubmit={newValues => {
updateReview.mutate(newValues, {onSuccess: () => onClose(true)});
}}
>
<StarSelector
className="-ml-8 mb-12"
count={10}
value={form.watch('score')}
onValueChange={newScore => {
form.setValue('score', newScore);
}}
/>
<FormTextField
name="title"
className="mb-24"
label={<Trans message="Title" />}
labelSuffix={<Trans message="10 character minimum" />}
autoFocus
minLength={10}
required
/>
<FormTextField
className="mb-24"
name="body"
label={<Trans message="Review" />}
labelSuffix={<Trans message="100 character minimum" />}
inputElementType="textarea"
rows={5}
minLength={100}
required
/>
<Button
size="xs"
variant="outline"
color="primary"
type="submit"
className="mr-6"
disabled={updateReview.isPending}
>
<Trans message="Save" />
</Button>
<Button
size="xs"
variant="outline"
className="mr-6"
onClick={e => onClose(false)}
disabled={updateReview.isPending}
>
<Trans message="Cancel" />
</Button>
</Form>
);
}
interface UserDisplayNameProps {
user: User;
show: 'display_name' | 'email';
}
function UserDisplayName({user, show}: UserDisplayNameProps) {
const {auth} = useContext(SiteConfigContext);
if (auth.getUserProfileLink) {
return (
<Link
to={auth.getUserProfileLink(user)}
className={LinkStyle}
target="_blank"
>
{user[show]}
</Link>
);
}
return <div className="text-muted">{user[show]}</div>;
}

View File

@@ -0,0 +1,89 @@
import {
ALL_PRIMITIVE_OPERATORS,
BackendFilter,
FilterControlType,
FilterOperator,
} from '@common/datatable/filters/backend-filter';
import {message} from '@common/i18n/message';
import {USER_MODEL} from '@common/auth/user';
import {
createdAtFilter,
updatedAtFilter,
} from '@common/datatable/filters/timestamp-filters';
import {TITLE_MODEL} from '@app/titles/models/title';
export const ReviewsDatatableFilters: BackendFilter[] = [
{
key: 'user_id',
label: message('User'),
description: message('User review was created by'),
defaultOperator: FilterOperator.eq,
control: {
type: FilterControlType.SelectModel,
model: USER_MODEL,
},
},
{
key: 'reviewable_id',
label: message('Title'),
description: message('Movie or series review was created for'),
defaultOperator: FilterOperator.eq,
extraFilters: [
{
key: 'reviewable_type',
operator: FilterOperator.eq,
value: 'App\\Title',
},
],
control: {
type: FilterControlType.SelectModel,
model: TITLE_MODEL,
},
},
{
key: 'score',
label: message('Score'),
description: message('Review score'),
defaultOperator: FilterOperator.gte,
operators: ALL_PRIMITIVE_OPERATORS,
control: {
type: FilterControlType.Input,
inputType: 'number',
minValue: 1,
maxValue: 10,
defaultValue: 7,
},
},
{
key: 'helpful_count',
label: message('Helpful count'),
description: message('How many users found this review helpful'),
defaultOperator: FilterOperator.gte,
operators: ALL_PRIMITIVE_OPERATORS,
control: {
type: FilterControlType.Input,
inputType: 'number',
minValue: 1,
defaultValue: 10,
},
},
{
key: 'not_helpful_count',
label: message('Not helpful count'),
description: message('How many users found this review not helpful'),
defaultOperator: FilterOperator.gte,
operators: ALL_PRIMITIVE_OPERATORS,
control: {
type: FilterControlType.Input,
inputType: 'number',
minValue: 1,
defaultValue: 10,
},
},
createdAtFilter({
description: message('Date review was created'),
}),
updatedAtFilter({
description: message('Date review was last updated'),
}),
];

View File

@@ -0,0 +1,162 @@
import React, { useCallback, useMemo, useState } from "react";
import { Trans } from "@common/i18n/trans";
import clsx from "clsx";
import { StaticPageTitle } from "@common/seo/static-page-title";
import { DataTableHeader } from "@common/datatable/data-table-header";
import {
useBackendFilterUrlParams
} from "@common/datatable/filters/backend-filter-url-params";
import {
GetDatatableDataParams,
useDatatableData
} from "@common/datatable/requests/paginated-resources";
import { FilterList } from "@common/datatable/filters/filter-list/filter-list";
import {
SelectedStateDatatableHeader
} from "@common/datatable/selected-state-datatable-header";
import { AnimatePresence } from "framer-motion";
import {
DataTablePaginationFooter
} from "@common/datatable/data-table-pagination-footer";
import {
DataTableEmptyStateMessage
} from "@common/datatable/page/data-table-emty-state-message";
import reviewsImage from "./reviews.svg";
import { FullPageLoader } from "@common/ui/progress/full-page-loader";
import { Review } from "@app/titles/models/review";
import { DeleteReviewsButton } from "@app/admin/reviews/delete-reviews-button";
import { ReviewDatatableItem } from "@app/admin/reviews/review-datatable-item";
import {
ReviewsDatatableFilters
} from "@app/admin/reviews/reviews-datatable-filters";
import {
ReviewListSortButton
} from "@app/reviews/review-list/review-list-sort-button";
import { Reviewable } from "@app/reviews/reviewable";
interface Props {
hideTitle?: boolean;
reviewable?: Reviewable;
}
export function ReviewsDatatablePage({hideTitle, reviewable}: Props) {
const filters = useMemo(() => {
return ReviewsDatatableFilters.filter(
f => f.key !== 'reviewable_id' || !reviewable,
);
}, [reviewable]);
const {encodedFilters} = useBackendFilterUrlParams(filters);
const [params, setParams] = useState<GetDatatableDataParams>({perPage: 15});
const [selectedReviews, setSelectedReviews] = useState<number[]>([]);
const [sort, setSort] = useState<string>('created_at:desc');
const [orderBy, orderDir] = sort.split(':');
const query = useDatatableData<Review>('reviews', {
...params,
orderBy,
orderDir: orderDir as 'asc' | 'desc',
with: 'reviewable,user',
filters: encodedFilters,
reviewable_type: reviewable?.model_type,
reviewable_id: reviewable?.id,
}, undefined, () => {
setSelectedReviews([]);
});
const toggleReview = useCallback(
(id: number) => {
const newValues = [...selectedReviews];
if (!newValues.includes(id)) {
newValues.push(id);
} else {
const index = newValues.indexOf(id);
newValues.splice(index, 1);
}
setSelectedReviews(newValues);
},
[selectedReviews, setSelectedReviews],
);
const isFiltering = !!(params.query || params.filters || encodedFilters);
const pagination = query.data?.pagination;
return (
<div className={clsx(!hideTitle && 'p-12 md:p-24')}>
<div className={clsx('mb-16')}>
<StaticPageTitle>
<Trans message="Reviews" />
</StaticPageTitle>
{!hideTitle && (
<h1 className="text-3xl font-light">
<Trans message="Reviews" />
</h1>
)}
</div>
<div>
<AnimatePresence initial={false} mode="wait">
{selectedReviews.length ? (
<SelectedStateDatatableHeader
selectedItemsCount={selectedReviews.length}
actions={
<DeleteReviewsButton
size="sm"
variant="flat"
reviewIds={selectedReviews}
/>
}
key="selected"
/>
) : (
<DataTableHeader
key="default"
filters={filters}
searchValue={params.query}
onSearchChange={query => setParams({...params, query})}
actions={
<ReviewListSortButton
value={sort}
onValueChange={newSort => setSort(newSort)}
color="primary"
showReportsItem
/>
}
/>
)}
</AnimatePresence>
<FilterList className="mb-14" filters={filters} />
{query.isLoading ? (
<FullPageLoader className="min-h-200" />
) : (
<div className="border-x border-t rounded">
{pagination?.data.map(review => (
<ReviewDatatableItem
key={review.id}
review={review}
isSelected={selectedReviews.includes(review.id)}
onToggle={() => toggleReview(review.id)}
/>
))}
</div>
)}
{(query.isFetched || query.isPlaceholderData) &&
!pagination?.data.length ? (
<DataTableEmptyStateMessage
className="pt-50"
isFiltering={isFiltering}
image={reviewsImage}
title={<Trans message="No reviews have been created yet" />}
filteringTitle={<Trans message="No matching reviews" />}
/>
) : undefined}
<DataTablePaginationFooter
className="mt-10"
query={query}
onPageChange={page => setParams({...params, page})}
onPerPageChange={perPage => setParams({...params, perPage})}
/>
</div>
</div>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,40 @@
import {CustomFilterControl} from '@common/datatable/filters/backend-filter';
import {Fragment} from 'react';
import {Skeleton} from '@common/ui/skeleton/skeleton';
import {Avatar} from '@common/ui/images/avatar';
import {FilterListItemDialogTrigger} from '@common/datatable/filters/filter-list/filter-list-item-dialog-trigger';
import {FilterListControlProps} from '@common/datatable/filters/filter-list/filter-list-control';
import {useNormalizedModel} from '@common/users/queries/use-normalized-model';
export function TitleFilterControl(
props: FilterListControlProps<number, CustomFilterControl>,
) {
const {value, filter} = props;
const {isLoading, data} = useNormalizedModel(
`normalized-models/title/${value}`,
);
const skeleton = (
<Fragment>
<Skeleton variant="avatar" size="w-18 h-18 mr-6" />
<Skeleton variant="rect" size="w-50" />
</Fragment>
);
const modelPreview = (
<Fragment>
<Avatar size="xs" src={data?.model.image} className="mr-6" />
{data?.model.name}
</Fragment>
);
const label = isLoading || !data ? skeleton : modelPreview;
const Panel = filter.control.panel;
return (
<FilterListItemDialogTrigger
{...props}
label={label}
panel={<Panel filter={filter} />}
/>
);
}

View File

@@ -0,0 +1,19 @@
import {
BackendFilter,
CustomFilterControl,
} from '@common/datatable/filters/backend-filter';
import React from 'react';
import {TitleSelect} from '@app/titles/title-select';
interface Props {
filter: BackendFilter<CustomFilterControl>;
}
export function TitleFilterPanel({filter}: Props) {
return (
<TitleSelect
name={`${filter.key}.value`}
seasonName={`${filter.key}.season`}
episodeName={`${filter.key}.episode`}
/>
);
}

View File

@@ -0,0 +1,8 @@
import {SettingsNavItem} from '@common/admin/settings/settings-nav-config';
import {message} from '@common/i18n/message';
export const AppSettingsNavConfig: SettingsNavItem[] = [
{label: message('Local search'), to: 'search'},
{label: message('Content'), to: 'content'},
{label: message('Videos'), to: 'videos'},
];

View File

@@ -0,0 +1,19 @@
import {RouteObject} from 'react-router-dom';
import {VideoSettings} from '@app/admin/settings/video-settings';
import {ContentSettings} from '@app/admin/settings/content-settings/content-settings';
import {SearchSettings} from '@common/admin/settings/pages/search-settings/search-settings';
export const AppSettingsRoutes: RouteObject[] = [
{
path: 'search',
element: <SearchSettings />,
},
{
path: 'videos',
element: <VideoSettings />,
},
{
path: 'content',
element: <ContentSettings />,
},
];

View File

@@ -0,0 +1,152 @@
import {Fragment} from 'react';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {Trans} from '@common/i18n/trans';
import {FormSelect} from '@common/ui/forms/select/select';
import {Item} from '@common/ui/forms/listbox/item';
import {SettingsSeparator} from '@common/admin/settings/settings-separator';
import {useFormContext} from 'react-hook-form';
import {AdminSettings} from '@common/admin/settings/admin-settings';
import {SettingsErrorGroup} from '@common/admin/settings/settings-error-group';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {useValueLists} from '@common/http/value-lists';
export function ContentSettingsAutomationPanel() {
const {watch} = useFormContext<AdminSettings>();
return (
<Fragment>
<SearchMethodSelect />
<FormSwitch
className="mb-24"
name="client.content.title_provider"
value="tmdb"
description={
<Trans message="This will automatically import, and periodically update, all metadata available on TheMovieDB about the title when user visits that title's page." />
}
>
<Trans message="Title automation" />
</FormSwitch>
<FormSwitch
className="mb-24"
name="client.content.force_season_update"
value="tmdb"
description={
<Trans message="When this is enabled, season episodes will be automatically updated, even if title automation is disabled." />
}
>
<Trans message="Always update seasons" />
</FormSwitch>
<SettingsSeparator />
<FormSwitch
className="mb-24"
name="client.content.people_provider"
value="tmdb"
description={
<Trans message="This will automatically import, and periodically update, all metadata available on TheMovieDB about a person, when user visits that person's page." />
}
>
<Trans message="People automation" />
</FormSwitch>
{watch('client.content.people_provider') === 'tmdb' && (
<FormSwitch
className="mb-24"
name="client.content.automate_filmography"
description={
<Trans message="Whether full filmograpy for a person should be imported from TheMovieDB when auto updating the person metadata." />
}
>
<Trans message="Full filmography" />
</FormSwitch>
)}
<TmdbFields />
</Fragment>
);
}
function SearchMethodSelect() {
return (
<FormSelect
className="mb-24"
name="client.content.search_provider"
selectionMode="single"
label={<Trans message="Search method" />}
description={
<Trans message="Which method should be used for user facing search on the site." />
}
>
<Item
value="tmdb"
description={
<Trans message="Search on the site will directly connect to, and search TheMovieDB. Any movie, series and artist available on TheMovieDB will be discoverable via search, without needing to import or create it first." />
}
>
<Trans message="TheMovieDB" />
</Item>
<Item
value="local"
description={
<Trans message="Will only search content that was created or imported from admin area. This can be further configured from 'Local search' settings page." />
}
>
<Trans message="Local" />
</Item>
<Item
value="all"
description={
<Trans message="Will combine search results from both 'Local' and 'TheMovieDB' methods. If there are identical matches, local results will be preferred." />
}
>
<Trans message="Local and TheMovieDB" />
</Item>
</FormSelect>
);
}
function TmdbFields() {
const {data} = useValueLists(['tmdbLanguages']);
const {watch: w} = useFormContext<AdminSettings>();
const shouldShow = [
w('client.content.people_provider'),
w('client.content.title_provider'),
w('client.content.search_provider'),
].some(provider => `${provider}`.toLowerCase().includes('tmdb'));
if (!shouldShow) {
return null;
}
return (
<SettingsErrorGroup name="tmdb_group" separatorBottom={false}>
{isInvalid => (
<Fragment>
<FormTextField
invalid={isInvalid}
name="server.tmdb_api_key"
label={<Trans message="TheMovieDB API Key" />}
className="mb-24"
required
/>
<FormSelect
className="mb-24"
selectionMode="single"
showSearchField
invalid={isInvalid}
name="client.tmdb.language"
label={<Trans message="TheMovieDB language" />}
description={
<Trans message="In what language should content be fetched from TMDb. If translation is not available, data will be in original language for that movie or series." />
}
>
{data?.tmdbLanguages.map(({code, name}) => (
<Item value={code} key={code}>
{name}
</Item>
))}
</FormSelect>
<FormSwitch name="client.tmdb.includeAdult">
<Trans message="Import adult content" />
</FormSwitch>
</Fragment>
)}
</SettingsErrorGroup>
);
}

View File

@@ -0,0 +1,80 @@
import {useFormContext} from 'react-hook-form';
import {AdminSettings} from '@common/admin/settings/admin-settings';
import {Fragment} from 'react';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {Trans} from '@common/i18n/trans';
import {FormSelect} from '@common/ui/forms/select/select';
import {Item} from '@common/ui/forms/listbox/item';
export function ContentSettingsGeneralPanel() {
const {watch} = useFormContext<AdminSettings>();
return (
<Fragment>
<SortingMethodSelect />
<FormSwitch
className="mb-24"
name="client.titles.enable_reviews"
description={
<Trans
message={
'Enable or disable all review functionality across the site.'
}
/>
}
>
<Trans message="Enable reviews" />
</FormSwitch>
<FormSwitch
className="mb-24"
name="client.titles.enable_comments"
description={
<Trans
message={
'Enable or disable all comment functionality across the site.'
}
/>
}
>
<Trans message="Enable comments" />
</FormSwitch>
{watch('client.titles.enable_comments') && (
<FormSwitch
name="client.comments.per_video"
description={
<Trans
message={
'When enabled, individual videos will have their own separate comment section (if there are multiple videos), otherwise comments will be shared by all videos for the same title.'
}
/>
}
>
<Trans message="Per video comments" />
</FormSwitch>
)}
</Fragment>
);
}
function SortingMethodSelect() {
return (
<FormSelect
className="mb-24"
name="server.rating_column"
label={<Trans message="Rating used for sorting" />}
selectionMode="single"
description={
<Trans
message="When ordering titles by rating, should local user rating or TheMovieDB rating average be
used."
/>
}
>
<Item value="tmdb_vote_average">
<Trans message="TheMovieDB" />
</Item>
<Item value="local_vote_average">
<Trans message="Local (Ratings and reviews from site users)" />
</Item>
</FormSelect>
);
}

View File

@@ -0,0 +1,156 @@
import {Trans} from '@common/i18n/trans';
import React, {Fragment, ReactNode, useRef, useState} from 'react';
import {DragPreviewRenderer} from '@common/ui/interactions/dnd/use-draggable';
import {useFormContext} from 'react-hook-form';
import {AdminSettingsWithFiles} from '@common/admin/settings/requests/update-admin-settings';
import {moveItemInNewArray} from '@common/utils/array/move-item-in-new-array';
import {IconButton} from '@common/ui/buttons/icon-button';
import {DragHandleIcon} from '@common/icons/material/DragHandle';
import {Checkbox} from '@common/ui/forms/toggle/checkbox';
import {DragPreview} from '@common/ui/interactions/dnd/drag-preview';
import clsx from 'clsx';
import {TitlePageSections} from '@app/titles/pages/title-page/sections/title-page-sections';
import {MessageDescriptor} from '@common/i18n/message-descriptor';
import {AdminSettings} from '@common/admin/settings/admin-settings';
import {useSortable} from '@common/ui/interactions/dnd/sortable/use-sortable';
interface SectionItem {
name: (typeof TitlePageSections)[number];
title: MessageDescriptor;
}
const defaultItems: SectionItem[] = [
{name: 'episodes', title: {message: 'Episode grid'}},
{name: 'seasons', title: {message: 'Season grid'}},
{name: 'videos', title: {message: 'Video grid'}},
{name: 'images', title: {message: 'Image grid'}},
{name: 'reviews', title: {message: 'Reviews'}},
{name: 'cast', title: {message: 'Cast grid'}},
{name: 'related', title: {message: 'Related titles'}},
];
export function ContentSettingsTitlePagePanel() {
const {getValues, setValue} = useFormContext<AdminSettings>();
const getSavedValue = (): string[] => {
return getValues('client.title_page.sections') || [];
};
const [items, setItems] = useState(() => {
const savedValue = getSavedValue();
const sortFn = (x: string) =>
savedValue.includes(x) ? savedValue.indexOf(x) : savedValue.length;
return [...defaultItems].sort((a, b) => sortFn(a.name) - sortFn(b.name));
});
return (
<div>
<div className="mb-14 text-sm">
<Trans message="Title page sections" />
<div className="text-xs text-muted">
<Trans message="Select which sections should appear on title page and in which order." />
</div>
</div>
{items.map((section, index) => (
<ListItemLayout
items={items}
isFirst={index === 0}
key={section.name}
section={section}
title={<Trans {...section.title} />}
onToggle={(section, checked) => {
const savedValue = getSavedValue();
const newValue = checked
? [...savedValue, section.name]
: savedValue.filter(x => x !== section.name);
setValue('client.title_page.sections', newValue as any);
}}
onSortEnd={(oldIndex, newIndex) => {
const sortedItems = moveItemInNewArray(items, oldIndex, newIndex);
setItems(sortedItems);
const savedValue = getSavedValue();
const newValue = sortedItems
.filter(x => savedValue.includes(x.name))
.map(x => x.name);
setValue('client.title_page.sections', newValue);
}}
/>
))}
</div>
);
}
interface ListItemLayoutProps {
isFirst: boolean;
items: SectionItem[];
section: SectionItem;
title: ReactNode;
onSortEnd: (oldIndex: number, newIndex: number) => void;
onToggle: (section: SectionItem, checked: boolean) => void;
}
function ListItemLayout({
isFirst,
title,
items,
section,
onSortEnd,
onToggle,
}: ListItemLayoutProps) {
const ref = useRef<HTMLDivElement>(null);
const previewRef = useRef<DragPreviewRenderer>(null);
const {watch} = useFormContext<AdminSettingsWithFiles>();
const savedValue = watch('client.title_page.sections') || [];
const isChecked = savedValue.includes(section.name);
const {sortableProps, dragHandleRef} = useSortable({
ref,
item: section,
items,
type: 'titlePageSections',
preview: previewRef,
strategy: 'line',
onSortEnd,
});
return (
<Fragment>
<div
className={clsx(
'flex w-full items-center gap-8 border-b py-6',
isFirst && 'border-t border-t-transparent',
)}
ref={ref}
{...sortableProps}
>
<IconButton ref={dragHandleRef}>
<DragHandleIcon />
</IconButton>
<div className="flex-auto">
<div className="text-sm">{title}</div>
</div>
<Checkbox
checked={isChecked}
onChange={() => {
onToggle(section, !isChecked);
}}
/>
</div>
<TabDragPreview title={title} ref={previewRef} />
</Fragment>
);
}
interface DragPreviewProps {
title: ReactNode;
}
const TabDragPreview = React.forwardRef<DragPreviewRenderer, DragPreviewProps>(
({title}, ref) => {
return (
<DragPreview ref={ref}>
{() => (
<div className="rounded bg-chip p-8 text-sm shadow">{title}</div>
)}
</DragPreview>
);
},
);

View File

@@ -0,0 +1,45 @@
import {Trans} from '@common/i18n/trans';
import {SettingsPanel} from '@common/admin/settings/settings-panel';
import {Tabs} from '@common/ui/tabs/tabs';
import {TabList} from '@common/ui/tabs/tab-list';
import {Tab} from '@common/ui/tabs/tab';
import {TabPanel, TabPanels} from '@common/ui/tabs/tab-panels';
import {ContentSettingsGeneralPanel} from '@app/admin/settings/content-settings/content-settings-general-panel';
import {ContentSettingsAutomationPanel} from '@app/admin/settings/content-settings/content-settings-automation-panel';
import {ContentSettingsTitlePagePanel} from '@app/admin/settings/content-settings/content-settings-title-page-panel';
export function ContentSettings() {
return (
<SettingsPanel
title={<Trans message="Content" />}
description={
<Trans message="Control how content is displayed across the site." />
}
>
<Tabs>
<TabList>
<Tab width="min-w-132">
<Trans message="General" />
</Tab>
<Tab width="min-w-132">
<Trans message="Automation" />
</Tab>
<Tab width="min-w-132">
<Trans message="Title page" />
</Tab>
</TabList>
<TabPanels className="pt-24">
<TabPanel>
<ContentSettingsGeneralPanel />
</TabPanel>
<TabPanel>
<ContentSettingsAutomationPanel />
</TabPanel>
<TabPanel>
<ContentSettingsTitlePagePanel />
</TabPanel>
</TabPanels>
</Tabs>
</SettingsPanel>
);
}

View File

@@ -0,0 +1,122 @@
import {Trans} from '@common/i18n/trans';
import {FormSelect} from '@common/ui/forms/select/select';
import {Item} from '@common/ui/forms/listbox/item';
import {SettingsPanel} from '@common/admin/settings/settings-panel';
import {FormSwitch} from '@common/ui/forms/toggle/switch';
import {JsonChipField} from '@common/admin/settings/json-chip-field';
import {useTrans} from '@common/i18n/use-trans';
export function VideoSettings() {
const {trans} = useTrans();
return (
<SettingsPanel
title={<Trans message="Video and streaming" />}
description={
<Trans message="Control how videos are played and displayed on the site." />
}
>
<ShownVideoTypeSelect />
<SortingMethodSelect />
<FormSwitch
className="mb-24"
name="client.streaming.prefer_full"
description={
<Trans
message={
'When user clicks on "play" buttons across the site play full movie or episode instead of trailers and clips.'
}
/>
}
>
<Trans message="Prefer full videos" />
</FormSwitch>
<FormSwitch
className="mb-24"
name="client.streaming.show_video_selector"
description={
<Trans message="Show alternative videos on the watch page." />
}
>
<Trans message="Alternative videos" />
</FormSwitch>
<FormSwitch
className="mb-24"
name="client.streaming.show_header_play"
description={
<Trans message="Whether play button should be shown on main title header." />
}
>
<Trans message="Header play button" />
</FormSwitch>
<JsonChipField
className="mb-24"
label={<Trans message="Possible video qualities" />}
name="client.streaming.qualities"
placeholder={trans({message: 'Add another...'})}
/>
</SettingsPanel>
);
}
function SortingMethodSelect() {
return (
<FormSelect
className="mb-24"
name="client.streaming.default_sort"
label={<Trans message="Video sorting" />}
selectionMode="single"
description={
<Trans message="When multiple videos are shown on the page, how should they be sorted by default." />
}
>
<Item value="order:asc">
<Trans message="Manual (order assigned manually in admin area)" />
</Item>
<Item value="created_at:desc">
<Trans message="Date added" />
</Item>
<Item value="name:asc">
<Trans message="Name (a-z)" />
</Item>
<Item value="Language:asc">
<Trans message="Language (a-z)" />
</Item>
<Item value="reports:asc">
<Trans message="Reports (videos with less reports first)" />
</Item>
<Item value="score:desc">
<Trans message="Score (most liked videos first)" />
</Item>
</FormSelect>
);
}
function ShownVideoTypeSelect() {
return (
<FormSelect
className="mb-24"
name="client.streaming.video_panel_content"
label={<Trans message="Shown videos" />}
selectionMode="single"
description={
<Trans message="What type of videos should be shown in title and episode pages (if there is more then one video attached)." />
}
>
<Item value="all">
<Trans message="All videos" />
</Item>
<Item value="full">
<Trans message="Full movies and episodes" />
</Item>
<Item value="short">
<Trans message="Short videos (everything except full movies & episodes)" />
</Item>
<Item value="trailer">
<Trans message="Trailers" />
</Item>
<Item value="clip">
<Trans message="Clips" />
</Item>
</FormSelect>
);
}

View File

@@ -0,0 +1,88 @@
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 from 'react';
import {
CreateTitleTagPayload,
useCreateTitleTag,
} from '@app/admin/title-tags/title-tags-editor/requests/use-create-title-tag';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Props {
type: TitleTag['model_type'];
}
export function CreateTitleTagDialog({type}: Props) {
const displayName = type.replace('_', ' ');
const {formId, close} = useDialogContext();
const form = useForm<CreateTitleTagPayload>();
const addTag = useCreateTitleTag(form, type);
return (
<Dialog>
<DialogHeader>
<Trans message="Add :name" values={{name: displayName}} />
</DialogHeader>
<DialogBody>
<Form
id={formId}
form={form}
onSubmit={async values => {
await addTag.mutate(values, {
onSuccess: () => {
toast(message(':name created', {values: {name: displayName}}));
close();
},
});
}}
>
<FormTextField
name="name"
label={<Trans message="Name" />}
description={
<Trans
message="Unique :name identifier."
values={{name: displayName}}
/>
}
className="mb-20"
required
autoFocus
/>
<FormTextField
name="display_name"
label={<Trans message="Display name" />}
description={
<Trans
message="User friendly :name name."
values={{name: displayName}}
/>
}
className="mb-20"
/>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={() => close()}>
<Trans message="Cancel" />
</Button>
<Button
form={formId}
type="submit"
variant="flat"
color="primary"
disabled={addTag.isPending}
>
<Trans message="Create" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,38 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {UseFormReturn} from 'react-hook-form';
import {TitleTag} from '@app/admin/titles/requests/use-detach-title-tag';
interface Response extends BackendResponse {
tag: TitleTag;
}
export interface CreateTitleTagPayload {
name: string;
display_name: string;
}
export function useCreateTitleTag(
form: UseFormReturn<CreateTitleTagPayload>,
type: TitleTag['model_type'],
) {
return useMutation({
mutationFn: (props: CreateTitleTagPayload) => createNewTag(props, type),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('title-tags'),
});
},
onError: err => onFormQueryError(err, form),
});
}
function createNewTag(
payload: CreateTitleTagPayload,
type: TitleTag['model_type'],
): Promise<Response> {
return apiClient.post(`title-tags/${type}`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,36 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {UseFormReturn} from 'react-hook-form';
import {TitleTag} from '@app/admin/titles/requests/use-detach-title-tag';
import {CreateTitleTagPayload} from '@app/admin/title-tags/title-tags-editor/requests/use-create-title-tag';
interface Response extends BackendResponse {
tag: TitleTag;
}
export function useUpdateTitleTag(
form: UseFormReturn<CreateTitleTagPayload>,
tag: TitleTag,
) {
return useMutation({
mutationFn: (payload: CreateTitleTagPayload) => updateTag(payload, tag),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('title-tags'),
});
},
onError: err => onFormQueryError(err, form),
});
}
function updateTag(
payload: CreateTitleTagPayload,
tag: TitleTag,
): Promise<Response> {
return apiClient
.put(`title-tags/${tag.model_type}/${tag.id}`, payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,15 @@
import {BackendFilter} from '@common/datatable/filters/backend-filter';
import {
createdAtFilter,
updatedAtFilter,
} from '@common/datatable/filters/timestamp-filters';
import {message} from '@common/i18n/message';
export const TitleTagsDatatableFilters: BackendFilter[] = [
createdAtFilter({
description: message('Date item was created'),
}),
updatedAtFilter({
description: message('Date item was last updated'),
}),
];

View File

@@ -0,0 +1,106 @@
import {ColumnConfig} from '@common/datatable/column-config';
import {Trans} from '@common/i18n/trans';
import React, {Fragment} from 'react';
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
import {IconButton} from '@common/ui/buttons/icon-button';
import {TitleTag} from '@app/admin/titles/requests/use-detach-title-tag';
import {FormattedDate} from '@common/i18n/formatted-date';
import {EditIcon} from '@common/icons/material/Edit';
import {DataTablePage} from '@common/datatable/page/data-table-page';
import {DeleteSelectedItemsAction} from '@common/datatable/page/delete-selected-items-action';
import {DataTableEmptyStateMessage} from '@common/datatable/page/data-table-emty-state-message';
import softwareEngineerSvg from '@common/admin/tags/software-engineer.svg';
import {DataTableAddItemButton} from '@common/datatable/data-table-add-item-button';
import {CreateTitleTagDialog} from '@app/admin/title-tags/title-tags-editor/create-title-tag-dialog';
import {UpdateTitleTagDialog} from '@app/admin/title-tags/title-tags-editor/update-title-tag-dialog';
import {TitleTagsDatatableFilters} from '@app/admin/title-tags/title-tags-editor/title-tags-datatable-filters';
const columnConfig: ColumnConfig<TitleTag>[] = [
{
key: 'name',
allowsSorting: true,
visibleInMode: 'all',
width: 'flex-3 min-w-200',
header: () => <Trans message="Name" />,
body: tag => tag.name,
},
{
key: 'display_name',
allowsSorting: true,
header: () => <Trans message="Display name" />,
body: tag => tag.display_name,
},
{
key: 'updated_at',
allowsSorting: true,
width: 'w-100',
header: () => <Trans message="Last updated" />,
body: tag => <FormattedDate date={tag.updated_at} />,
},
{
key: 'actions',
header: () => <Trans message="Actions" />,
hideHeader: true,
align: 'end',
width: 'w-42 flex-shrink-0',
visibleInMode: 'all',
body: tag => (
<DialogTrigger type="modal">
<IconButton size="md" className="text-muted">
<EditIcon />
</IconButton>
<UpdateTitleTagDialog tag={tag} />
</DialogTrigger>
),
},
];
interface Props {
type: TitleTag['model_type'];
}
export function TitleTagsDatatablePage({type}: Props) {
const displayType = `${type.replace('_', ' ')}s`;
return (
<DataTablePage
endpoint={`title-tags/${type}`}
title={<Trans message={displayType} />}
columns={columnConfig}
filters={TitleTagsDatatableFilters}
actions={<Actions type={type} />}
selectedActions={<DeleteSelectedItemsAction />}
emptyStateMessage={
<DataTableEmptyStateMessage
image={softwareEngineerSvg}
title={
<Trans
message="No :name have been created yet"
values={{name: displayType}}
/>
}
filteringTitle={
<Trans message="No matching :name" values={{name: displayType}} />
}
/>
}
/>
);
}
interface ActionsProps {
type: TitleTag['model_type'];
}
function Actions({type}: ActionsProps) {
return (
<Fragment>
<DialogTrigger type="modal">
<DataTableAddItemButton>
<Trans
message="Add new :name"
values={{name: type.replace('_', ' ')}}
/>
</DataTableAddItemButton>
<CreateTitleTagDialog type={type} />
</DialogTrigger>
</Fragment>
);
}

View File

@@ -0,0 +1,91 @@
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 from 'react';
import {CreateTitleTagPayload} from '@app/admin/title-tags/title-tags-editor/requests/use-create-title-tag';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {useUpdateTitleTag} from '@app/admin/title-tags/title-tags-editor/requests/use-update-title-tag';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Props {
tag: TitleTag;
}
export function UpdateTitleTagDialog({tag}: Props) {
const displayName = tag.model_type.replace('_', ' ');
const {formId, close} = useDialogContext();
const form = useForm<CreateTitleTagPayload>({
defaultValues: {
name: tag.name,
display_name: tag.display_name,
},
});
const updateTag = useUpdateTitleTag(form, tag);
return (
<Dialog>
<DialogHeader>
<Trans message="Add :name" values={{name: displayName}} />
</DialogHeader>
<DialogBody>
<Form
id={formId}
form={form}
onSubmit={async values => {
updateTag.mutate(values, {
onSuccess: () => {
toast(message(':name updated', {values: {name: displayName}}));
close();
},
});
}}
>
<FormTextField
name="name"
label={<Trans message="Name" />}
description={
<Trans
message="Unique :name identifier."
values={{name: displayName}}
/>
}
className="mb-20"
required
autoFocus
/>
<FormTextField
name="display_name"
label={<Trans message="Display name" />}
description={
<Trans
message="User friendly :name name."
values={{name: displayName}}
/>
}
className="mb-20"
/>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={() => close()}>
<Trans message="Cancel" />
</Button>
<Button
form={formId}
type="submit"
variant="flat"
color="primary"
disabled={updateTag.isPending}
>
<Trans message="Save" />
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -0,0 +1,313 @@
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 {useForm} from 'react-hook-form';
import {Form} from '@common/ui/forms/form';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {DialogFooter} from '@common/ui/overlays/dialog/dialog-footer';
import {Button} from '@common/ui/buttons/button';
import {FormChipField} from '@common/ui/forms/input-field/chip-field/form-chip-field';
import {useValueLists} from '@common/http/value-lists';
import {Item} from '@common/ui/forms/listbox/item';
import {FormDateRangePicker} from '@common/ui/forms/input-field/date/date-range-picker/form-date-range-picker';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {InfoDialogTriggerIcon} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger-icon';
import React, {Fragment, useState} from 'react';
import {FormTextField} from '@common/ui/forms/input-field/text-field/text-field';
import {
ImportMultipleFromTmdbFormValue,
ImportMultipleProgressData,
useImportMultipleFromTmdb,
} from '@app/admin/titles/requests/use-import-multiple-from-tmdb';
import {ProgressBar} from '@common/ui/progress/progress-bar';
import {Skeleton} from '@common/ui/skeleton/skeleton';
export function ImportMultipleFromTmdbDialog() {
const form = useForm<ImportMultipleFromTmdbFormValue>({
defaultValues: {
type: 'movie',
start_from_page: 1,
pages_to_import: 10,
},
});
const {formId, close} = useDialogContext();
const importTitles = useImportMultipleFromTmdb();
const [activePanel, setActivePanel] = useState<'form' | 'progress'>('form');
const [progressData, setProgressData] =
useState<ImportMultipleProgressData>();
return (
<Dialog size="lg">
<DialogHeader>
<Trans message="Import from TheMovieDB" />
</DialogHeader>
<DialogBody>
<Form
id={formId}
form={form}
onSubmit={values => {
importTitles.mutate(values, {
onProgress: data => {
setActivePanel('progress');
setProgressData(data);
},
});
}}
>
{progressData ? <ProgressPanel data={progressData} /> : <FormPanel />}
</Form>
</DialogBody>
<DialogFooter>
<Button
onClick={() => {
if (progressData) {
importTitles.cancel();
setProgressData(undefined);
setActivePanel('form');
} else {
close();
}
}}
>
{importTitles.isLoading || activePanel === 'form' ? (
<Trans message="Cancel" />
) : (
<Trans message="Back" />
)}
</Button>
<Button
form={formId}
variant="flat"
color="primary"
type="submit"
disabled={importTitles.isLoading || activePanel === 'progress'}
>
<Trans message="Import" />
</Button>
</DialogFooter>
</Dialog>
);
}
function FormPanel() {
return (
<Fragment>
<p className="mb-24 flex items-center gap-8 text-muted">
<InfoDialogTriggerIcon size="xs" viewBox="0 0 16 16" />
<Trans message="All filters below are optional and can be left empty." />
</p>
<FormSelect
name="type"
label={<Trans message="Type" />}
className="mb-24"
selectionMode="single"
>
<Option value="movie">
<Trans message="Movie" />
</Option>
<Option value="series">
<Trans message="Series" />
</Option>
</FormSelect>
<PaginationFields />
<GenreChipField />
<KeywordChipField />
<LanguageSelect />
<CountrySelect />
<RatingFields />
<FormDateRangePicker
name="release_date"
granularity="day"
label={<Trans message="Release date" />}
description={
<Trans message="Only import titles released between specified dates." />
}
/>
</Fragment>
);
}
interface ProgressPanelProps {
data: ImportMultipleProgressData;
}
function ProgressPanel({data}: ProgressPanelProps) {
return (
<div>
<ProgressBar
value={data.progress}
label={
<Trans
message="Imported :number titles of :total"
values={{number: data.currentItem, total: data.totalItems}}
/>
}
/>
<div className="compact-scrollbar mt-24 h-400 overflow-auto text-xs">
{data.titleList.map((title, index) => (
<div key={index}>{title}</div>
))}
{!data.titleList.length ? (
<Fragment>
{[...new Array(20).keys()].map(index => (
<Skeleton className="mb-2 max-w-200" key={index} />
))}
</Fragment>
) : null}
</div>
</div>
);
}
function PaginationFields() {
return (
<div className="mb-24">
<div className="flex items-center gap-24">
<FormTextField
className="flex-1"
name="start_from_page"
label={<Trans message="Starting page" />}
type="number"
min={1}
max={500}
/>
<FormTextField
className="flex-1"
name="pages_to_import"
label={<Trans message="How many pages to import" />}
type="number"
min={1}
max={500}
/>
</div>
<p className="pt-10 text-xs text-muted">
<Trans message="20 titles per page are imported. " />
</p>
</div>
);
}
function GenreChipField() {
const {data} = useFilterValueLists();
const genres = data?.genres.map(genre => ({
id: genre.value,
name: genre.name,
}));
return (
<FormChipField
className="mb-24"
name="genres"
label={<Trans message="Genres" />}
suggestions={genres}
allowCustomValue={false}
description={
<Trans message="Only import titles belonging to specified genres." />
}
>
{genre => (
<Item value={genre.id}>
<Trans message={genre.name} />
</Item>
)}
</FormChipField>
);
}
function KeywordChipField() {
const {data} = useFilterValueLists();
const keywords = data?.keywords.map(keyword => ({
id: keyword.value,
name: keyword.name,
}));
return (
<FormChipField
name="keywords"
className="mb-24"
label={<Trans message="Keywords" />}
suggestions={keywords}
allowCustomValue={false}
description={
<Trans message="Only import titles that have specied keywords attached." />
}
>
{keyword => (
<Item value={keyword.id}>
<Trans message={keyword.name} />
</Item>
)}
</FormChipField>
);
}
function LanguageSelect() {
const {data} = useFilterValueLists();
return (
<FormSelect
name="language"
className="mb-24"
label={<Trans message="Language" />}
items={data?.languages}
selectionMode="single"
description={
<Trans message="Only import titles with specied primary spoken language." />
}
>
{language => (
<Item value={language.code}>
<Trans message={language.name} />
</Item>
)}
</FormSelect>
);
}
function CountrySelect() {
const {data} = useFilterValueLists();
return (
<FormSelect
name="country"
className="mb-24"
label={<Trans message="Country" />}
items={data?.countries}
selectionMode="single"
description={
<Trans message="Only import titles with specied origin country." />
}
>
{country => (
<Item value={country.code}>
<Trans message={country.name} />
</Item>
)}
</FormSelect>
);
}
function RatingFields() {
return (
<div className="mb-24 flex items-center gap-24">
<FormTextField
className="flex-1"
name="min_rating"
label={<Trans message="Minimum rating" />}
type="number"
min={1}
max={10}
/>
<FormTextField
className="flex-1"
name="max_rating"
label={<Trans message="Maximum rating" />}
type="number"
min={1}
max={10}
/>
</div>
);
}
function useFilterValueLists() {
return useValueLists(['genres', 'keywords', 'languages', 'countries'], {
type: 'tmdb',
});
}

View File

@@ -0,0 +1,90 @@
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 {useForm} from 'react-hook-form';
import {Form} from '@common/ui/forms/form';
import {useDialogContext} from '@common/ui/overlays/dialog/dialog-context';
import {FormTextField} 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 {
ImportMediaItemPayload,
useImportSingleFromTmdb,
} from '@app/admin/titles/requests/use-import-single-from-tmdb';
import {FormSelect, Option} from '@common/ui/forms/select/select';
import {TITLE_MODEL} from '@app/titles/models/title';
import {PERSON_MODEL} from '@app/titles/models/person';
interface ImportFromTmdbDialogProps {
modelType: typeof TITLE_MODEL | typeof PERSON_MODEL;
}
export function ImportSingleFromTmdbDialog({
modelType,
}: ImportFromTmdbDialogProps) {
const form = useForm<ImportMediaItemPayload>({
defaultValues: {
media_type: modelType === TITLE_MODEL ? 'movie' : 'person',
},
});
const {formId, close} = useDialogContext();
const importItem = useImportSingleFromTmdb();
return (
<Dialog>
<DialogHeader>
<Trans message="Import from TheMovieDB" />
</DialogHeader>
<DialogBody>
<Form
id={formId}
form={form}
onSubmit={values => {
importItem.mutate(values, {
onSuccess: response => {
close(response.mediaItem);
},
});
}}
>
{modelType === TITLE_MODEL && (
<FormSelect
name="media_type"
label={<Trans message="Type" />}
className="mb-24"
selectionMode="single"
>
<Option value="movie">
<Trans message="Movie" />
</Option>
<Option value="series">
<Trans message="Series" />
</Option>
</FormSelect>
)}
<FormTextField
autoFocus
required
name="tmdb_id"
min={1}
type="number"
label={<Trans message="TheMovieDB ID" />}
/>
</Form>
</DialogBody>
<DialogFooter>
<Button onClick={() => close()}>
<Trans message="Cancel" />
</Button>
<Button
form={formId}
variant="flat"
color="primary"
type="submit"
disabled={importItem.isPending}
>
<Trans message="Import" />
</Button>
</DialogFooter>
</Dialog>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,43 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {useParams} from 'react-router-dom';
import {TitleTag} from '@app/admin/titles/requests/use-detach-title-tag';
import {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
interface Response extends BackendResponse {}
export interface AttachTitleTagPayload {
tag_name: string;
}
export function useAttachTitleTag(
form: UseFormReturn<AttachTitleTagPayload>,
tagType: TitleTag['model_type'],
) {
const {titleId} = useParams();
return useMutation({
mutationFn: (payload: AttachTitleTagPayload) =>
attachTag(titleId!, tagType, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['titles', `${titleId}`],
});
toast(message('Tag attached'));
},
onError: r => onFormQueryError(r, form),
});
}
function attachTag(
titleId: number | string,
tagType: TitleTag['model_type'],
payload: AttachTitleTagPayload,
): Promise<Response> {
return apiClient
.post(`titles/${titleId}/tags/${tagType}`, payload)
.then(r => r.data);
}

View File

@@ -0,0 +1,33 @@
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 {titleSeasonsQueryKey} from '@app/titles/requests/use-title-seasons';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {Season} from '@app/titles/models/season';
interface Response extends BackendResponse {
season: Season;
}
export function useCreateSeason(titleId: number) {
return useMutation({
mutationFn: () => createSeason(titleId),
onSuccess: async response => {
await queryClient.invalidateQueries({
queryKey: titleSeasonsQueryKey(response.season.title_id),
});
toast(
message('Season :number created', {
values: {number: response.season.number},
}),
);
},
onError: r => showHttpErrorToast(r),
});
}
function createSeason(titleId: number): Promise<Response> {
return apiClient.post(`titles/${titleId}/seasons`).then(r => r.data);
}

View File

@@ -0,0 +1,53 @@
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 {useParams} from 'react-router-dom';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {titleCreditsQueryKey} from '@app/admin/titles/requests/use-title-credits';
interface Response extends BackendResponse {
//
}
export interface CreateTitleCreditPayload {
person_id: number;
character: string;
department: string;
job: string;
season?: number | string;
episode?: number | string;
}
export function useCreateTitleCredit(
form: UseFormReturn<CreateTitleCreditPayload>,
) {
const {titleId, season, episode} = useParams();
return useMutation({
mutationFn: (payload: CreateTitleCreditPayload) =>
createCredit(titleId!, season, episode, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: titleCreditsQueryKey(titleId!),
});
toast(message('Credit added'));
},
onError: r => onFormQueryError(r, form),
});
}
function createCredit(
titleId: number | string,
season: number | string | undefined,
episode: number | string | undefined,
payload: CreateTitleCreditPayload,
): Promise<Response> {
payload = {
...payload,
season,
episode,
};
return apiClient.post(`titles/${titleId}/credits`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,45 @@
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 {UseFormReturn} from 'react-hook-form';
import {onFormQueryError} from '@common/errors/on-form-query-error';
import {Title} from '@app/titles/models/title';
import {CreateVideoPayload} from '@app/admin/videos/requests/use-create-video';
interface Response extends BackendResponse {
title: Title;
}
export interface CreateTitlePayload {
name: string;
original_title: string;
is_series: boolean;
poster: string;
backdrop: string;
release_date: string;
tagline: string;
description: string;
runtime: number;
certification: string;
budget: number;
revenue: number;
language: string;
popularity: number;
images: {url: string}[];
videos: CreateVideoPayload[];
}
export function useCreateTitle(form: UseFormReturn<CreateTitlePayload>) {
return useMutation({
mutationFn: (payload: CreateTitlePayload) => createTitle(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['titles']});
},
onError: r => (form ? onFormQueryError(r, form) : showHttpErrorToast(r)),
});
}
function createTitle(payload: CreateTitlePayload): Promise<Response> {
return apiClient.post(`titles`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,25 @@
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 {useParams} from 'react-router-dom';
interface Response extends BackendResponse {}
export function useDeleteImage(imageId: number | string) {
const {titleId} = useParams();
return useMutation({
mutationFn: () => deleteImage(imageId),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['titles', `${titleId}`]});
toast(message('Image deleted'));
},
onError: r => showHttpErrorToast(r),
});
}
function deleteImage(imageId: number | string): Promise<Response> {
return apiClient.delete(`images/${imageId}`).then(r => r.data);
}

View File

@@ -0,0 +1,27 @@
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 {titleSeasonsQueryKey} from '@app/titles/requests/use-title-seasons';
import {Title} from '@app/titles/models/title';
interface Response extends BackendResponse {}
export function useDeleteSeason(title: Title, seasonId: number | string) {
return useMutation({
mutationFn: () => deleteSeason(seasonId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: titleSeasonsQueryKey(title.id),
});
toast(message('Season deleted'));
},
onError: r => showHttpErrorToast(r),
});
}
function deleteSeason(seasonId: number | string): Promise<Response> {
return apiClient.delete(`seasons/${seasonId}`).then(r => r.data);
}

View File

@@ -0,0 +1,37 @@
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 {useParams} from 'react-router-dom';
import {titleCreditsQueryKey} from '@app/admin/titles/requests/use-title-credits';
interface Response extends BackendResponse {}
export function useDeleteTitleCredit(creditId: number) {
const {titleId, season, episode} = useParams();
return useMutation({
mutationFn: () => deleteCredit(titleId!, season, episode, creditId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: titleCreditsQueryKey(titleId!),
});
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,32 @@
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 {useParams} from 'react-router-dom';
import {Keyword} from '@app/titles/models/keyword';
import {Genre} from '@app/titles/models/genre';
import {ProductionCountry} from '@app/titles/models/production-country';
interface Response extends BackendResponse {}
export type TitleTag = Keyword | Genre | ProductionCountry;
export function useDetachTitleTag(tag: TitleTag) {
const {titleId} = useParams();
return useMutation({
mutationFn: () => detachTag(titleId!, tag),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: ['titles', `${titleId}`]});
toast(message('Tag detached'));
},
onError: r => showHttpErrorToast(r),
});
}
function detachTag(titleId: number | string, tag: TitleTag): Promise<Response> {
return apiClient
.delete(`titles/${titleId}/tags/${tag.model_type}/${tag.id}`)
.then(r => r.data);
}

View File

@@ -0,0 +1,203 @@
import {useTrans} from '@common/i18n/use-trans';
import {ChipValue} from '@common/ui/forms/input-field/chip-field/chip-field';
import {useCallback, useRef, useState} from 'react';
import {apiClient, queryClient} from '@common/http/query-client';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {Title} from '@app/titles/models/title';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
interface Response extends BackendResponse {
titles: Title[];
total_pages: number;
}
export interface ImportMultipleFromTmdbFormValue {
type: 'movie' | 'series';
country?: string;
language?: string;
min_rating?: string;
max_rating?: string;
genres?: ChipValue[];
keywords?: ChipValue[];
release_date?: {
start?: string;
end?: string;
};
pages_to_import?: number;
start_from_page?: number;
current_page?: number;
}
interface Payload
extends Omit<
ImportMultipleFromTmdbFormValue,
'genres' | 'keywords' | 'release_date'
> {
genres?: string;
keywords?: string;
start_date?: string;
end_date?: string;
}
export interface ImportMultipleProgressData {
totalItems: number;
currentItem: number;
progress: number;
titleList: string[];
}
interface MutateOptions {
onSuccess?: () => void;
onProgress?: (data: ImportMultipleProgressData) => void;
}
export function useImportMultipleFromTmdb() {
const {trans} = useTrans();
const titlesList = useRef<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const controller = useRef(new AbortController());
const cancel = useCallback(() => {
controller.current.abort('canceled');
}, []);
const handler = useCallback(
async (v: ImportMultipleFromTmdbFormValue, options: MutateOptions) => {
let stopped = false;
let error = false;
let pagesToImport = v.pages_to_import ? +v.pages_to_import : 1;
const startFromPage = v.start_from_page ? +v.start_from_page : 1;
if (pagesToImport + startFromPage > 500) {
pagesToImport = 500 - startFromPage;
}
const stopImporting = () => {
setIsLoading(false);
titlesList.current = [];
controller.current = new AbortController();
stopped = true;
};
let currentPage = startFromPage;
setIsLoading(true);
controller.current.signal.addEventListener('abort', () =>
stopImporting(),
);
let index = 0;
while (index <= pagesToImport && !stopped) {
// open progress bar instantly, instead of waiting for first response to come back
if (index === 0) {
options.onProgress?.({
totalItems: pagesToImport * 20,
currentItem: 0,
progress: 0,
titleList: [],
});
}
index++;
currentPage++;
try {
const response = await apiClient
.post<Response>(
'tmdb/import',
formValueToPayload({...v, current_page: currentPage}),
{
signal: controller.current.signal,
},
)
.then(r => r.data);
if (response.total_pages < pagesToImport) {
pagesToImport = response.total_pages;
}
// limit array to 1000 items
if (titlesList.current.length > 1000) {
titlesList.current = titlesList.current.slice(0, 1000);
}
titlesList.current.unshift(...response.titles.map(t => t.name));
const totalItems = pagesToImport * 20;
const currentItem = (index - 1) * 20;
options.onProgress?.({
totalItems: totalItems,
currentItem: currentItem,
progress: Math.round((currentItem / totalItems) * 100),
titleList: titlesList.current,
});
} catch (e) {
stopImporting();
error = true;
if ((e as any).message !== 'canceled') {
console.error(e);
showHttpErrorToast(e);
}
}
}
if (!error) {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('titles'),
});
toast(trans(message('Titles imported')));
setIsLoading(false);
options.onSuccess?.();
}
},
[trans],
);
return {
mutate: handler,
cancel,
isLoading,
};
}
function formValueToPayload(values: ImportMultipleFromTmdbFormValue): Payload {
const payload: Payload = {
type: values.type,
pages_to_import: values.pages_to_import,
start_from_page: values.start_from_page,
current_page: values.current_page,
};
if (values.country) {
payload.country = values.country;
}
if (values.language) {
payload.language = values.language;
}
if (values.min_rating) {
payload.min_rating = values.min_rating;
}
if (values.max_rating) {
payload.max_rating = values.max_rating;
}
if (values.genres) {
payload.genres = values.genres.map(genre => genre.id).join(',');
}
if (values.keywords) {
payload.keywords = values.keywords.map(keyword => keyword.id).join(',');
}
if (values.release_date) {
payload.start_date = values.release_date.start;
payload.end_date = values.release_date.start;
}
return payload;
}

View File

@@ -0,0 +1,37 @@
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {useTrans} from '@common/i18n/use-trans';
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {DatatableDataQueryKey} from '@common/datatable/requests/paginated-resources';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
import {Title} from '@app/titles/models/title';
import {Person} from '@app/titles/models/person';
interface Response extends BackendResponse {
mediaItem: Title | Person;
}
export interface ImportMediaItemPayload {
tmdb_id: string;
media_type: 'movie' | 'series' | 'person';
}
export function useImportSingleFromTmdb() {
const {trans} = useTrans();
return useMutation({
mutationFn: (props: ImportMediaItemPayload) => importMediaItem(props),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: DatatableDataQueryKey('titles'),
});
toast(trans(message('Item imported')));
},
onError: err => showHttpErrorToast(err),
});
}
function importMediaItem(payload: ImportMediaItemPayload): Promise<Response> {
return apiClient.post('media/import', payload).then(r => r.data);
}

View File

@@ -0,0 +1,34 @@
import {BackendResponse} from '@common/http/backend-response/backend-response';
import {useMutation} from '@tanstack/react-query';
import {apiClient, queryClient} from '@common/http/query-client';
import {useParams} from 'react-router-dom';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {titleCreditsQueryKey} from '@app/admin/titles/requests/use-title-credits';
import {showHttpErrorToast} from '@common/utils/http/show-http-error-toast';
interface Response extends BackendResponse {
//
}
interface Payload {
ids: number[];
}
export function useSortTitleCredits() {
const {titleId} = useParams();
return useMutation({
mutationFn: (payload: Payload) => sortCredits(payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: titleCreditsQueryKey(titleId!),
});
toast(message('Credit added'));
},
onError: r => showHttpErrorToast(r),
});
}
function sortCredits(payload: Payload): Promise<Response> {
return apiClient.post(`titles/credits/reorder`, payload).then(r => r.data);
}

View File

@@ -0,0 +1,41 @@
import {useInfiniteData} from '@common/ui/infinite-scroll/use-infinite-data';
import {TitleCredit} from '@app/titles/models/title';
import {useParams} from 'react-router-dom';
export const titleCreditsQueryKey = (
titleId: number | string,
season?: number | string,
episode?: number | string,
params?: any
) => {
const key = ['titles', `${titleId}`, 'credits'];
if (season) {
key.push('season', `${season}`);
}
if (episode) {
key.push('episode', `${episode}`);
}
if (params) {
key.push(params);
}
return key;
};
interface Params {
department?: string;
crewOnly?: string;
}
export function useTitleCredits(params: Params = {}) {
const {titleId, season, episode} = useParams();
return useInfiniteData<TitleCredit>({
endpoint: `titles/${titleId}/credits`,
queryKey: titleCreditsQueryKey(titleId!, season, episode, params),
queryParams: {
...params,
perPage: 30,
season: season || '',
episode: episode || '',
},
});
}

View File

@@ -0,0 +1,52 @@
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 {useParams} from 'react-router-dom';
import {toast} from '@common/ui/toast/toast';
import {message} from '@common/i18n/message';
import {titleCreditsQueryKey} from '@app/admin/titles/requests/use-title-credits';
import {CreateTitleCreditPayload} from '@app/admin/titles/requests/use-create-title-credit';
interface Response extends BackendResponse {
//
}
export interface UpdateTitleCreditPayload
extends Omit<CreateTitleCreditPayload, 'person_id'> {}
export function useUpdateTitleCredit(
form: UseFormReturn<UpdateTitleCreditPayload>,
creditId: number,
) {
const {titleId, season, episode} = useParams();
return useMutation({
mutationFn: (payload: UpdateTitleCreditPayload) =>
updateTitle(titleId!, season, episode, creditId, payload),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: titleCreditsQueryKey(titleId!),
});
toast(message('Credit updated'));
},
onError: r => onFormQueryError(r, form),
});
}
function updateTitle(
titleId: string,
season: string | undefined,
episode: string | undefined,
creditId: number,
payload: UpdateTitleCreditPayload,
): Promise<Response> {
payload = {
...payload,
season,
episode,
};
return apiClient
.put(`titles/${titleId}/credits/${creditId}`, payload)
.then(r => r.data);
}

Some files were not shown because too many files have changed in this diff Show More