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" />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user