first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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