349
resources/client/admin/app-admin-routes.tsx
Executable file
349
resources/client/admin/app-admin-routes.tsx
Executable 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} />,
|
||||
},
|
||||
];
|
||||
131
resources/client/admin/appearance/app-appearance-config.tsx
Executable file
131
resources/client/admin/appearance/app-appearance-config.tsx
Executable 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}"]`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
38
resources/client/admin/channels/channel-auto-update-field.tsx
Executable file
38
resources/client/admin/channels/channel-auto-update-field.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
254
resources/client/admin/channels/channel-content-config.tsx
Executable file
254
resources/client/admin/channels/channel-content-config.tsx
Executable 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;
|
||||
};
|
||||
24
resources/client/admin/channels/channel-content-item-image.tsx
Executable file
24
resources/client/admin/channels/channel-content-item-image.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
140
resources/client/admin/channels/channel-restriction-field.tsx
Executable file
140
resources/client/admin/channels/channel-restriction-field.tsx
Executable 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
resources/client/admin/channels/channel-seo-fields.tsx
Executable file
26
resources/client/admin/channels/channel-seo-fields.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
98
resources/client/admin/channels/create-channel-page.tsx
Executable file
98
resources/client/admin/channels/create-channel-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
115
resources/client/admin/channels/edit-channel-page.tsx
Executable file
115
resources/client/admin/channels/edit-channel-page.tsx
Executable 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
113
resources/client/admin/lists/lists-datatable-columns.tsx
Executable file
113
resources/client/admin/lists/lists-datatable-columns.tsx
Executable 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
36
resources/client/admin/lists/lists-datatable-page.tsx
Executable file
36
resources/client/admin/lists/lists-datatable-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
resources/client/admin/lists/todo.svg
Executable file
1
resources/client/admin/lists/todo.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 5.6 KiB |
66
resources/client/admin/news/create-news-article-page.tsx
Executable file
66
resources/client/admin/news/create-news-article-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
94
resources/client/admin/news/edit-news-article-page.tsx
Executable file
94
resources/client/admin/news/edit-news-article-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
117
resources/client/admin/news/news-datatable-columns.tsx
Executable file
117
resources/client/admin/news/news-datatable-columns.tsx
Executable 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()},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
resources/client/admin/news/news-datatable-filters.ts
Executable file
15
resources/client/admin/news/news-datatable-filters.ts
Executable 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'),
|
||||
}),
|
||||
];
|
||||
62
resources/client/admin/news/news-datatable-page.tsx
Executable file
62
resources/client/admin/news/news-datatable-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
resources/client/admin/news/online-articles.svg
Executable file
1
resources/client/admin/news/online-articles.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 14 KiB |
33
resources/client/admin/news/requests/use-create-news-article.ts
Executable file
33
resources/client/admin/news/requests/use-create-news-article.ts
Executable 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);
|
||||
}
|
||||
30
resources/client/admin/news/requests/use-delete-news-article.ts
Executable file
30
resources/client/admin/news/requests/use-delete-news-article.ts
Executable 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);
|
||||
}
|
||||
23
resources/client/admin/news/requests/use-import-news-articles.ts
Executable file
23
resources/client/admin/news/requests/use-import-news-articles.ts
Executable 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);
|
||||
}
|
||||
32
resources/client/admin/news/requests/use-news-article.ts
Executable file
32
resources/client/admin/news/requests/use-news-article.ts
Executable 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);
|
||||
}
|
||||
33
resources/client/admin/news/requests/use-update-news-article.ts
Executable file
33
resources/client/admin/news/requests/use-update-news-article.ts
Executable 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);
|
||||
}
|
||||
2
resources/client/admin/people/awards.svg
Executable file
2
resources/client/admin/people/awards.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
45
resources/client/admin/people/crupdate/create-person-page.tsx
Executable file
45
resources/client/admin/people/crupdate/create-person-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
156
resources/client/admin/people/crupdate/person-credits-editor.tsx
Executable file
156
resources/client/admin/people/crupdate/person-credits-editor.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
113
resources/client/admin/people/crupdate/person-primary-facts-form.tsx
Executable file
113
resources/client/admin/people/crupdate/person-primary-facts-form.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
93
resources/client/admin/people/crupdate/update-person-page.tsx
Executable file
93
resources/client/admin/people/crupdate/update-person-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
87
resources/client/admin/people/people-datatable-columns.tsx
Executable file
87
resources/client/admin/people/people-datatable-columns.tsx
Executable 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
120
resources/client/admin/people/people-datatable-filters.tsx
Executable file
120
resources/client/admin/people/people-datatable-filters.tsx
Executable 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'),
|
||||
}),
|
||||
];
|
||||
72
resources/client/admin/people/people-datatable-page.tsx
Executable file
72
resources/client/admin/people/people-datatable-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
39
resources/client/admin/people/requests/use-create-person.ts
Executable file
39
resources/client/admin/people/requests/use-create-person.ts
Executable 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);
|
||||
}
|
||||
42
resources/client/admin/people/requests/use-delete-person-credit.ts
Executable file
42
resources/client/admin/people/requests/use-delete-person-credit.ts
Executable 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);
|
||||
}
|
||||
36
resources/client/admin/people/requests/use-update-person.ts
Executable file
36
resources/client/admin/people/requests/use-update-person.ts
Executable 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);
|
||||
}
|
||||
39
resources/client/admin/reports/admin-insights-report.tsx
Executable file
39
resources/client/admin/reports/admin-insights-report.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
19
resources/client/admin/reports/admin-visitors-report.tsx
Executable file
19
resources/client/admin/reports/admin-visitors-report.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
53
resources/client/admin/reports/insights/insights-async-chart.tsx
Executable file
53
resources/client/admin/reports/insights/insights-async-chart.tsx
Executable 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,
|
||||
});
|
||||
}
|
||||
14
resources/client/admin/reports/insights/insights-charts-context.ts
Executable file
14
resources/client/admin/reports/insights/insights-charts-context.ts
Executable 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);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-devices-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-devices-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-episodes-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-episodes-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
11
resources/client/admin/reports/insights/insights-locations-chart.tsx
Executable file
11
resources/client/admin/reports/insights/insights-locations-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-movies-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-movies-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
15
resources/client/admin/reports/insights/insights-platforms-chart.tsx
Executable file
15
resources/client/admin/reports/insights/insights-platforms-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
29
resources/client/admin/reports/insights/insights-plays-chart.tsx
Executable file
29
resources/client/admin/reports/insights/insights-plays-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-report-row.tsx
Executable file
12
resources/client/admin/reports/insights/insights-report-row.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-seasons-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-seasons-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-series-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-series-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-users-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-users-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
12
resources/client/admin/reports/insights/insights-videos-chart.tsx
Executable file
12
resources/client/admin/reports/insights/insights-videos-chart.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
96
resources/client/admin/reports/model-insights-page-layout.tsx
Executable file
96
resources/client/admin/reports/model-insights-page-layout.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
62
resources/client/admin/reports/mtdb-admin-report-page.tsx
Executable file
62
resources/client/admin/reports/mtdb-admin-report-page.tsx
Executable 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} />;
|
||||
}
|
||||
62
resources/client/admin/reports/pages/episode-insights-page.tsx
Executable file
62
resources/client/admin/reports/pages/episode-insights-page.tsx
Executable 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" />
|
||||
);
|
||||
}
|
||||
55
resources/client/admin/reports/pages/season-insights-page.tsx
Executable file
55
resources/client/admin/reports/pages/season-insights-page.tsx
Executable 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" />
|
||||
);
|
||||
}
|
||||
51
resources/client/admin/reports/pages/title-insights-page.tsx
Executable file
51
resources/client/admin/reports/pages/title-insights-page.tsx
Executable 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" />
|
||||
);
|
||||
}
|
||||
54
resources/client/admin/reports/pages/video-insights-page.tsx
Executable file
54
resources/client/admin/reports/pages/video-insights-page.tsx
Executable 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" />
|
||||
);
|
||||
}
|
||||
86
resources/client/admin/reports/requests/use-insights-report.ts
Executable file
86
resources/client/admin/reports/requests/use-insights-report.ts
Executable 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);
|
||||
}
|
||||
196
resources/client/admin/reports/top-models-chart-layout.tsx
Executable file
196
resources/client/admin/reports/top-models-chart-layout.tsx
Executable 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" />;
|
||||
}
|
||||
}
|
||||
58
resources/client/admin/reviews/delete-reviews-button.tsx
Executable file
58
resources/client/admin/reviews/delete-reviews-button.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
41
resources/client/admin/reviews/requests/use-update-review.ts
Executable file
41
resources/client/admin/reviews/requests/use-update-review.ts
Executable 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);
|
||||
}
|
||||
41
resources/client/admin/reviews/restore-comments-button.tsx
Executable file
41
resources/client/admin/reviews/restore-comments-button.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
246
resources/client/admin/reviews/review-datatable-item.tsx
Executable file
246
resources/client/admin/reviews/review-datatable-item.tsx
Executable 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>•</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>;
|
||||
}
|
||||
89
resources/client/admin/reviews/reviews-datatable-filters.tsx
Executable file
89
resources/client/admin/reviews/reviews-datatable-filters.tsx
Executable 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'),
|
||||
}),
|
||||
];
|
||||
162
resources/client/admin/reviews/reviews-datatable-page.tsx
Executable file
162
resources/client/admin/reviews/reviews-datatable-page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
resources/client/admin/reviews/reviews.svg
Executable file
1
resources/client/admin/reviews/reviews.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
40
resources/client/admin/reviews/title-filter/title-filter-control.tsx
Executable file
40
resources/client/admin/reviews/title-filter/title-filter-control.tsx
Executable 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
resources/client/admin/reviews/title-filter/title-filter-panel.tsx
Executable file
19
resources/client/admin/reviews/title-filter/title-filter-panel.tsx
Executable 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
resources/client/admin/settings/app-settings-nav-config.ts
Executable file
8
resources/client/admin/settings/app-settings-nav-config.ts
Executable 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'},
|
||||
];
|
||||
19
resources/client/admin/settings/app-settings-routes.tsx
Executable file
19
resources/client/admin/settings/app-settings-routes.tsx
Executable 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 />,
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
45
resources/client/admin/settings/content-settings/content-settings.tsx
Executable file
45
resources/client/admin/settings/content-settings/content-settings.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
122
resources/client/admin/settings/video-settings.tsx
Executable file
122
resources/client/admin/settings/video-settings.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'),
|
||||
}),
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
313
resources/client/admin/titles/import/import-multiple-from-tmdb-dialog.tsx
Executable file
313
resources/client/admin/titles/import/import-multiple-from-tmdb-dialog.tsx
Executable 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',
|
||||
});
|
||||
}
|
||||
90
resources/client/admin/titles/import/import-single-from-tmdb-dialog.tsx
Executable file
90
resources/client/admin/titles/import/import-single-from-tmdb-dialog.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
1
resources/client/admin/titles/movie-night.svg
Executable file
1
resources/client/admin/titles/movie-night.svg
Executable file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
43
resources/client/admin/titles/requests/use-attach-title-tag.ts
Executable file
43
resources/client/admin/titles/requests/use-attach-title-tag.ts
Executable 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);
|
||||
}
|
||||
33
resources/client/admin/titles/requests/use-create-season.ts
Executable file
33
resources/client/admin/titles/requests/use-create-season.ts
Executable 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);
|
||||
}
|
||||
53
resources/client/admin/titles/requests/use-create-title-credit.ts
Executable file
53
resources/client/admin/titles/requests/use-create-title-credit.ts
Executable 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);
|
||||
}
|
||||
45
resources/client/admin/titles/requests/use-create-title.ts
Executable file
45
resources/client/admin/titles/requests/use-create-title.ts
Executable 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);
|
||||
}
|
||||
25
resources/client/admin/titles/requests/use-delete-image.ts
Executable file
25
resources/client/admin/titles/requests/use-delete-image.ts
Executable 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);
|
||||
}
|
||||
27
resources/client/admin/titles/requests/use-delete-season.ts
Executable file
27
resources/client/admin/titles/requests/use-delete-season.ts
Executable 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);
|
||||
}
|
||||
37
resources/client/admin/titles/requests/use-delete-title-credit.ts
Executable file
37
resources/client/admin/titles/requests/use-delete-title-credit.ts
Executable 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);
|
||||
}
|
||||
32
resources/client/admin/titles/requests/use-detach-title-tag.ts
Executable file
32
resources/client/admin/titles/requests/use-detach-title-tag.ts
Executable 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);
|
||||
}
|
||||
203
resources/client/admin/titles/requests/use-import-multiple-from-tmdb.ts
Executable file
203
resources/client/admin/titles/requests/use-import-multiple-from-tmdb.ts
Executable 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;
|
||||
}
|
||||
37
resources/client/admin/titles/requests/use-import-single-from-tmdb.ts
Executable file
37
resources/client/admin/titles/requests/use-import-single-from-tmdb.ts
Executable 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);
|
||||
}
|
||||
34
resources/client/admin/titles/requests/use-sort-title-credits.ts
Executable file
34
resources/client/admin/titles/requests/use-sort-title-credits.ts
Executable 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);
|
||||
}
|
||||
41
resources/client/admin/titles/requests/use-title-credits.ts
Executable file
41
resources/client/admin/titles/requests/use-title-credits.ts
Executable 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 || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
52
resources/client/admin/titles/requests/use-update-title-credit.ts
Executable file
52
resources/client/admin/titles/requests/use-update-title-credit.ts
Executable 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);
|
||||
}
|
||||
30
resources/client/admin/titles/requests/use-update-title.ts
Executable file
30
resources/client/admin/titles/requests/use-update-title.ts
Executable file
@@ -0,0 +1,30 @@
|
||||
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 {Title} from '@app/titles/models/title';
|
||||
import {CreateTitlePayload} from '@app/admin/titles/requests/use-create-title';
|
||||
|
||||
interface Response extends BackendResponse {
|
||||
title: Title;
|
||||
}
|
||||
|
||||
export function useUpdateTitle(form: UseFormReturn<CreateTitlePayload>) {
|
||||
const {titleId} = useParams();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CreateTitlePayload) => updateTitle(titleId!, payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: ['titles']});
|
||||
},
|
||||
onError: r => onFormQueryError(r, form),
|
||||
});
|
||||
}
|
||||
|
||||
function updateTitle(
|
||||
titleId: string,
|
||||
payload: CreateTitlePayload,
|
||||
): Promise<Response> {
|
||||
return apiClient.put(`titles/${titleId}`, payload).then(r => r.data);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user