✨ Rework Media Request Stats Widget (#1344)
* ✨ Rework Media Request Stats Widget * 🎨 More code to do it better than last commit * ♻️ Resize improvement * 🐛 Empty Username handling * 🎨 widget as router input * ✨ Open links in new tab + media request scrollArea
This commit is contained in:
@@ -6,6 +6,9 @@
|
|||||||
"title": "Media requests list",
|
"title": "Media requests list",
|
||||||
"replaceLinksWithExternalHost": {
|
"replaceLinksWithExternalHost": {
|
||||||
"label": "Replace links with external host"
|
"label": "Replace links with external host"
|
||||||
|
},
|
||||||
|
"openInNewTab": {
|
||||||
|
"label": "Open links in new tab"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,18 +4,24 @@
|
|||||||
"description": "Statistics about your media requests",
|
"description": "Statistics about your media requests",
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Media requests stats",
|
"title": "Media requests stats",
|
||||||
"direction": {
|
"replaceLinksWithExternalHost": {
|
||||||
"label": "Direction of the layout.",
|
"label": "Replace links with external host"
|
||||||
"data":{
|
},
|
||||||
"row": "Horizontal",
|
"openInNewTab": {
|
||||||
"column": "Vertical"
|
"label": "Open links in new tab"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"stats": {
|
"mediaStats": {
|
||||||
|
"title": "Media Stats",
|
||||||
"pending": "Pending approvals",
|
"pending": "Pending approvals",
|
||||||
"tvRequests": "TV requests",
|
"tvRequests": "TV requests",
|
||||||
"movieRequests": "Movie requests"
|
"movieRequests": "Movie requests",
|
||||||
|
"approved": "Already approved",
|
||||||
|
"totalRequests": "Total"
|
||||||
|
},
|
||||||
|
"userStats": {
|
||||||
|
"title": "Top Users",
|
||||||
|
"requests": "Requests: {{number}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import { z } from 'zod';
|
|||||||
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
import { checkIntegrationsType } from '~/tools/client/app-properties';
|
||||||
import { getConfig } from '~/tools/config/getConfig';
|
import { getConfig } from '~/tools/config/getConfig';
|
||||||
import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile';
|
import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile';
|
||||||
import { MediaRequest } from '~/widgets/media-requests/media-request-types';
|
import { MediaRequest, Users } from '~/widgets/media-requests/media-request-types';
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
import { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile';
|
||||||
|
|
||||||
export const mediaRequestsRouter = createTRPCRouter({
|
export const mediaRequestsRouter = createTRPCRouter({
|
||||||
all: publicProcedure
|
allMedia: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
configName: z.string(),
|
configName: z.string(),
|
||||||
|
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
@@ -21,8 +23,6 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
||||||
);
|
);
|
||||||
|
|
||||||
Consola.log(`Retrieving media requests from ${apps.length} apps`);
|
|
||||||
|
|
||||||
const promises = apps.map((app): Promise<MediaRequest[]> => {
|
const promises = apps.map((app): Promise<MediaRequest[]> => {
|
||||||
const apiKey =
|
const apiKey =
|
||||||
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
|
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
|
||||||
@@ -32,14 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const body = (await response.json()) as OverseerrResponse;
|
const body = (await response.json()) as OverseerrResponse;
|
||||||
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
|
const appUrl = input.widget.properties.replaceLinksWithExternalHost
|
||||||
| MediaRequestListWidget
|
|
||||||
| undefined;
|
|
||||||
if (!mediaWidget) {
|
|
||||||
Consola.log('No media-requests-list found');
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
const appUrl = mediaWidget.properties.replaceLinksWithExternalHost
|
|
||||||
? app.behaviour.externalUrl
|
? app.behaviour.externalUrl
|
||||||
: app.url;
|
: app.url;
|
||||||
|
|
||||||
@@ -59,8 +52,9 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
type: item.type,
|
type: item.type,
|
||||||
name: genericItem.name,
|
name: genericItem.name,
|
||||||
userName: item.requestedBy.displayName,
|
userName: item.requestedBy.displayName,
|
||||||
userProfilePicture: constructAvatarUrl(appUrl, item),
|
userProfilePicture: constructAvatarUrl(appUrl, item.requestedBy.avatar),
|
||||||
userLink: `${appUrl}/users/${item.requestedBy.id}`,
|
userLink: `${appUrl}/users/${item.requestedBy.id}`,
|
||||||
|
userRequestCount: item.requestedBy.requestCount,
|
||||||
airDate: genericItem.airDate,
|
airDate: genericItem.airDate,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
|
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
|
||||||
@@ -85,17 +79,66 @@ export const mediaRequestsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return mediaRequests;
|
return mediaRequests;
|
||||||
}),
|
}),
|
||||||
|
users: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
configName: z.string(),
|
||||||
|
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const config = getConfig(input.configName);
|
||||||
|
|
||||||
|
const apps = config.apps.filter((app) =>
|
||||||
|
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
|
||||||
|
);
|
||||||
|
|
||||||
|
const promises = apps.map((app): Promise<Users[]> => {
|
||||||
|
const apiKey =
|
||||||
|
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
|
||||||
|
const headers: HeadersInit = { 'X-Api-Key': apiKey };
|
||||||
|
return fetch(`${app.url}/api/v1/user?take=25&skip=0&sort=requests`, {
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
const body = (await response.json()) as OverseerrUsers;
|
||||||
|
const appUrl = input.widget.properties.replaceLinksWithExternalHost
|
||||||
|
? app.behaviour.externalUrl
|
||||||
|
: app.url;
|
||||||
|
|
||||||
|
const users = await Promise.all(
|
||||||
|
body.results.map(async (user): Promise<Users> => {
|
||||||
|
return {
|
||||||
|
app: app.integration?.type ?? 'overseerr',
|
||||||
|
id: user.id,
|
||||||
|
userName: user.displayName,
|
||||||
|
userProfilePicture: constructAvatarUrl(appUrl, user.avatar),
|
||||||
|
userLink: `${appUrl}/users/${user.id}`,
|
||||||
|
userRequestCount: user.requestCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return Promise.resolve(users);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
Consola.error(`Failed to request users from Overseerr: ${err}`);
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const users = (await Promise.all(promises)).reduce((prev, cur) => prev.concat(cur), []);
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const constructAvatarUrl = (appUrl: string, item: OverseerrResponseItem) => {
|
const constructAvatarUrl = (appUrl: string, avatar: string) => {
|
||||||
const isAbsolute =
|
const isAbsolute = avatar.startsWith('http://') || avatar.startsWith('https://');
|
||||||
item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://');
|
|
||||||
|
|
||||||
if (isAbsolute) {
|
if (isAbsolute) {
|
||||||
return item.requestedBy.avatar;
|
return avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${appUrl}/${item.requestedBy.avatar}`;
|
return `${appUrl}/${avatar}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const retrieveDetailsForItem = async (
|
const retrieveDetailsForItem = async (
|
||||||
@@ -117,7 +160,7 @@ const retrieveDetailsForItem = async (
|
|||||||
backdropPath: series.backdropPath,
|
backdropPath: series.backdropPath,
|
||||||
posterPath: series.backdropPath,
|
posterPath: series.backdropPath,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
|
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
|
||||||
headers,
|
headers,
|
||||||
@@ -158,6 +201,10 @@ type OverseerrResponse = {
|
|||||||
results: OverseerrResponseItem[];
|
results: OverseerrResponseItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OverseerrUsers = {
|
||||||
|
results: OverseerrResponseItemUser[];
|
||||||
|
};
|
||||||
|
|
||||||
type OverseerrResponseItem = {
|
type OverseerrResponseItem = {
|
||||||
id: number;
|
id: number;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -176,4 +223,5 @@ type OverseerrResponseItemUser = {
|
|||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
|
requestCount: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
Image,
|
||||||
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -30,6 +31,10 @@ const definition = defineWidget({
|
|||||||
type: 'switch',
|
type: 'switch',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
},
|
},
|
||||||
|
openInNewTab: {
|
||||||
|
type: 'switch',
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
component: MediaRequestListTile,
|
component: MediaRequestListTile,
|
||||||
gridstack: {
|
gridstack: {
|
||||||
@@ -55,7 +60,8 @@ const useMediaRequestDecisionMutation = () => {
|
|||||||
const utils = api.useContext();
|
const utils = api.useContext();
|
||||||
const { mutateAsync } = api.overseerr.decide.useMutation({
|
const { mutateAsync } = api.overseerr.decide.useMutation({
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
utils.mediaRequest.all.invalidate();
|
utils.mediaRequest.allMedia.invalidate();
|
||||||
|
utils.mediaRequest.users.invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { t } = useTranslation('modules/media-requests-list');
|
const { t } = useTranslation('modules/media-requests-list');
|
||||||
@@ -93,7 +99,7 @@ const useMediaRequestDecisionMutation = () => {
|
|||||||
|
|
||||||
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
||||||
const { t } = useTranslation('modules/media-requests-list');
|
const { t } = useTranslation('modules/media-requests-list');
|
||||||
const { data, isLoading } = useMediaRequestQuery();
|
const { data, isLoading } = useMediaRequestQuery(widget);
|
||||||
// Use mutation to approve or deny a pending request
|
// Use mutation to approve or deny a pending request
|
||||||
const decideAsync = useMediaRequestDecisionMutation();
|
const decideAsync = useMediaRequestDecisionMutation();
|
||||||
|
|
||||||
@@ -125,115 +131,118 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<ScrollArea h="100%">
|
||||||
{countPendingApproval > 0 ? (
|
<Stack>
|
||||||
<Text>{t('pending', { countPendingApproval })}</Text>
|
{countPendingApproval > 0 ? (
|
||||||
) : (
|
<Text>{t('pending', { countPendingApproval })}</Text>
|
||||||
<Text>{t('nonePending')}</Text>
|
) : (
|
||||||
)}
|
<Text>{t('nonePending')}</Text>
|
||||||
{sortedData.map((item) => (
|
)}
|
||||||
<Card withBorder>
|
{sortedData.map((item) => (
|
||||||
<Flex wrap="wrap" justify="space-between" gap="md">
|
<Card radius="md" withBorder>
|
||||||
<Flex gap="md">
|
<Flex wrap="wrap" justify="space-between" gap="md">
|
||||||
<Image
|
<Flex gap="md">
|
||||||
src={item.posterPath}
|
|
||||||
width={30}
|
|
||||||
height={50}
|
|
||||||
alt="poster"
|
|
||||||
radius="xs"
|
|
||||||
withPlaceholder
|
|
||||||
/>
|
|
||||||
<Stack spacing={0}>
|
|
||||||
<Group spacing="xs">
|
|
||||||
{item.airDate && <Text>{item.airDate.split('-')[0]}</Text>}
|
|
||||||
<MediaRequestStatusBadge status={item.status} />
|
|
||||||
</Group>
|
|
||||||
<Text
|
|
||||||
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
|
||||||
lineClamp={1}
|
|
||||||
weight="bold"
|
|
||||||
component="a"
|
|
||||||
href={item.href}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Flex>
|
|
||||||
<Stack justify="center">
|
|
||||||
<Flex gap="xs">
|
|
||||||
<Image
|
<Image
|
||||||
src={item.userProfilePicture}
|
src={item.posterPath}
|
||||||
width={25}
|
width={30}
|
||||||
height={25}
|
height={50}
|
||||||
alt="requester avatar"
|
alt="poster"
|
||||||
radius="xl"
|
radius="xs"
|
||||||
withPlaceholder
|
withPlaceholder
|
||||||
/>
|
/>
|
||||||
<Text
|
<Stack spacing={0}>
|
||||||
component="a"
|
<Group spacing="xs">
|
||||||
href={item.userLink}
|
{item.airDate && <Text>{item.airDate.split('-')[0]}</Text>}
|
||||||
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
<MediaRequestStatusBadge status={item.status} />
|
||||||
>
|
</Group>
|
||||||
{item.userName}
|
<Text
|
||||||
</Text>
|
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||||
|
lineClamp={1}
|
||||||
|
weight="bold"
|
||||||
|
component="a"
|
||||||
|
href={item.href}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Stack justify="center">
|
||||||
|
<Flex gap="xs">
|
||||||
|
<Image
|
||||||
|
src={item.userProfilePicture}
|
||||||
|
width={25}
|
||||||
|
height={25}
|
||||||
|
alt="requester avatar"
|
||||||
|
radius="xl"
|
||||||
|
withPlaceholder
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
component="a"
|
||||||
|
href={item.userLink}
|
||||||
|
target={widget.properties.openInNewTab ? "_blank" : "_self"}
|
||||||
|
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
||||||
|
>
|
||||||
|
{item.userName}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{item.status === MediaRequestStatus.PendingApproval && (
|
{item.status === MediaRequestStatus.PendingApproval && (
|
||||||
<Group>
|
<Group>
|
||||||
<Tooltip label={t('tooltips.approve')} withArrow withinPortal>
|
<Tooltip label={t('tooltips.approve')} withArrow withinPortal>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
id: `approve ${item.id}`,
|
id: `approve ${item.id}`,
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
title: t('tooltips.approving'),
|
title: t('tooltips.approving'),
|
||||||
message: undefined,
|
message: undefined,
|
||||||
loading: true,
|
loading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await decideAsync({
|
await decideAsync({
|
||||||
request: item,
|
request: item,
|
||||||
isApproved: true,
|
isApproved: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconThumbUp />
|
<IconThumbUp />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t('tooltips.decline')} withArrow withinPortal>
|
<Tooltip label={t('tooltips.decline')} withArrow withinPortal>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await decideAsync({
|
await decideAsync({
|
||||||
request: item,
|
request: item,
|
||||||
isApproved: false,
|
isApproved: false,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconThumbDown />
|
<IconThumbDown />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={item.backdropPath}
|
src={item.backdropPath}
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
opacity={0.1}
|
opacity={0.1}
|
||||||
top={0}
|
top={0}
|
||||||
left={0}
|
left={0}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{ pointerEvents: 'none' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
import { Card, Flex, Stack, Text } from '@mantine/core';
|
import {
|
||||||
import { IconChartBar } from '@tabler/icons-react';
|
Avatar,
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Indicator,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
useMantineTheme,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useElementSize } from '@mantine/hooks';
|
||||||
|
import { IconChartBar, IconExternalLink } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'next-i18next';
|
import { useTranslation } from 'next-i18next';
|
||||||
|
|
||||||
import { defineWidget } from '../helper';
|
import { defineWidget } from '../helper';
|
||||||
import { WidgetLoading } from '../loading';
|
import { WidgetLoading } from '../loading';
|
||||||
import { IWidget } from '../widgets';
|
import { IWidget } from '../widgets';
|
||||||
import { useMediaRequestQuery } from './media-request-query';
|
import { useMediaRequestQuery, useUsersQuery } from './media-request-query';
|
||||||
import { MediaRequestStatus } from './media-request-types';
|
import { MediaRequestStatus } from './media-request-types';
|
||||||
|
|
||||||
const definition = defineWidget({
|
const definition = defineWidget({
|
||||||
id: 'media-requests-stats',
|
id: 'media-requests-stats',
|
||||||
icon: IconChartBar,
|
icon: IconChartBar,
|
||||||
options: {
|
options: {
|
||||||
direction: {
|
replaceLinksWithExternalHost: {
|
||||||
type: 'select',
|
type: 'switch',
|
||||||
defaultValue: 'row' as 'row' | 'column',
|
defaultValue: false,
|
||||||
data: [
|
},
|
||||||
{ value: 'row' },
|
openInNewTab: {
|
||||||
{ value: 'column' },
|
type: 'switch',
|
||||||
],
|
defaultValue: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
gridstack: {
|
gridstack: {
|
||||||
minWidth: 1,
|
minWidth: 2,
|
||||||
minHeight: 1,
|
minHeight: 2,
|
||||||
maxWidth: 12,
|
maxWidth: 12,
|
||||||
maxHeight: 12,
|
maxHeight: 12,
|
||||||
},
|
},
|
||||||
@@ -38,53 +49,134 @@ interface MediaRequestStatsWidgetProps {
|
|||||||
|
|
||||||
function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) {
|
function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) {
|
||||||
const { t } = useTranslation('modules/media-requests-stats');
|
const { t } = useTranslation('modules/media-requests-stats');
|
||||||
const { data, isFetching } = useMediaRequestQuery();
|
const {
|
||||||
|
data: mediaData,
|
||||||
|
isFetching: mediaFetching,
|
||||||
|
isLoading: mediaLoading,
|
||||||
|
} = useMediaRequestQuery(widget);
|
||||||
|
const {
|
||||||
|
data: usersData,
|
||||||
|
isFetching: usersFetching,
|
||||||
|
isLoading: usersLoading
|
||||||
|
} = useUsersQuery(widget);
|
||||||
|
const { ref, height } = useElementSize();
|
||||||
|
const { colorScheme } = useMantineTheme();
|
||||||
|
|
||||||
if (!data || isFetching) {
|
if (!mediaData || !usersData || mediaLoading || usersLoading) {
|
||||||
return <WidgetLoading />;
|
return (
|
||||||
|
<Stack ref={ref} h="100%">
|
||||||
|
<WidgetLoading />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appList: string[] = [];
|
||||||
|
mediaData.forEach((item) => {
|
||||||
|
if (!appList.includes(item.appId)) appList.push(item.appId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseStats: { label: string; number: number }[] = [
|
||||||
|
{
|
||||||
|
label: t('mediaStats.pending'),
|
||||||
|
number: mediaData.filter((x) => x.status === MediaRequestStatus.PendingApproval).length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mediaStats.tvRequests'),
|
||||||
|
number: mediaData.filter((x) => x.type === 'tv').length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mediaStats.movieRequests'),
|
||||||
|
number: mediaData.filter((x) => x.type === 'movie').length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mediaStats.approved'),
|
||||||
|
number: mediaData.filter((x) => x.status === MediaRequestStatus.Approved).length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('mediaStats.totalRequests'),
|
||||||
|
number: mediaData.length,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const users = usersData
|
||||||
|
.sort((x, y) => (x.userRequestCount > y.userRequestCount ? -1 : 1))
|
||||||
|
.slice(0, Math.trunc(height / 60));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex h="100%" gap={0} direction="column">
|
||||||
w="100%"
|
<Text mt={-5}>{t('mediaStats.title')}</Text>
|
||||||
h="100%"
|
<Card py={5} px={10} radius="md" style={{ overflow: 'unset' }} withBorder>
|
||||||
gap="md"
|
{baseStats.map((stat, index) => {
|
||||||
direction={ widget.properties.direction?? 'row' }
|
return (
|
||||||
>
|
<Group key={index} position="apart">
|
||||||
<StatCard
|
<Text color="dimmed" align="center" size="xs">
|
||||||
number={data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length}
|
{stat.label}
|
||||||
label={t('stats.pending')}
|
</Text>
|
||||||
/>
|
<Text align="center" size="xs">
|
||||||
<StatCard
|
{stat.number}
|
||||||
number={data.filter((x) => x.type === 'tv').length}
|
</Text>
|
||||||
label={t('stats.tvRequests')}
|
</Group>
|
||||||
/>
|
);
|
||||||
<StatCard
|
})}
|
||||||
number={data.filter((x) => x.type === 'movie').length}
|
</Card>
|
||||||
label={t('stats.movieRequests')}
|
<Text mt={2}>{t('userStats.title')}</Text>
|
||||||
/>
|
<Stack ref={ref} style={{ flex: 1 }} spacing={5} p={0} sx={{ overflow: 'hidden' }}>
|
||||||
|
{users.map((user) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={user.id}
|
||||||
|
p={0}
|
||||||
|
component="a"
|
||||||
|
href={user.userLink}
|
||||||
|
target={widget.properties.openInNewTab ? "_blank" : "_self"}
|
||||||
|
mah={95}
|
||||||
|
mih={55}
|
||||||
|
radius="md"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
spacing={5}
|
||||||
|
px={10}
|
||||||
|
py={5}
|
||||||
|
align="center"
|
||||||
|
h="100%"
|
||||||
|
display="flex"
|
||||||
|
style={{ flexDirection: 'row' }}
|
||||||
|
>
|
||||||
|
{appList.length > 1 && (
|
||||||
|
<Tooltip.Floating
|
||||||
|
label={user.app.charAt(0).toUpperCase() + user.app.slice(1)}
|
||||||
|
c={colorScheme === 'light' ? 'black' : 'dark.0'}
|
||||||
|
color={colorScheme === 'light' ? 'gray.2' : 'dark.4'}
|
||||||
|
>
|
||||||
|
<Indicator
|
||||||
|
withBorder
|
||||||
|
top={18}
|
||||||
|
left={8}
|
||||||
|
size={15}
|
||||||
|
ml={-5}
|
||||||
|
color={user.app === 'overseerr' ? '#ECB000' : '#6677CC'}
|
||||||
|
processing={mediaFetching || usersFetching}
|
||||||
|
children
|
||||||
|
/>
|
||||||
|
</Tooltip.Floating>
|
||||||
|
)}
|
||||||
|
<Avatar radius="xl" size={45} src={user.userProfilePicture} alt="user avatar" />
|
||||||
|
<Stack spacing={0} style={{ flex: 1 }}>
|
||||||
|
<Text>{user.userName}</Text>
|
||||||
|
<Text size="xs">
|
||||||
|
{t('userStats.requests', { number: user.userRequestCount })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconExternalLink size={20} />
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
number: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatCard = ({ number, label }: StatCardProps) => {
|
|
||||||
return (
|
|
||||||
<Card w="100%" h="100%" withBorder style={{flex:"1 1 auto"}}>
|
|
||||||
<Stack w="100%" h="100%" align="center" justify="center" spacing={0}>
|
|
||||||
<Text align="center">
|
|
||||||
{number}
|
|
||||||
</Text>
|
|
||||||
<Text color="dimmed" align="center" size="xs">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default definition;
|
export default definition;
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
import { useConfigContext } from '~/config/provider';
|
import { useConfigContext } from '~/config/provider';
|
||||||
|
import { MediaRequestListWidget } from './MediaRequestListTile';
|
||||||
|
import { MediaRequestStatsWidget } from './MediaRequestStatsTile';
|
||||||
import { api } from '~/utils/api';
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
export const useMediaRequestQuery = () => {
|
export const useMediaRequestQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => {
|
||||||
const { name: configName } = useConfigContext();
|
const { name: configName } = useConfigContext();
|
||||||
return api.mediaRequest.all.useQuery(
|
return api.mediaRequest.allMedia.useQuery(
|
||||||
{ configName: configName! },
|
{ configName: configName!, widget: widget },
|
||||||
|
{
|
||||||
|
refetchInterval: 3 * 60 * 1000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUsersQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => {
|
||||||
|
const { name: configName } = useConfigContext();
|
||||||
|
return api.mediaRequest.users.useQuery(
|
||||||
|
{ configName: configName!, widget: widget },
|
||||||
{
|
{
|
||||||
refetchInterval: 3 * 60 * 1000,
|
refetchInterval: 3 * 60 * 1000,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type MediaRequest = {
|
|||||||
userName: string;
|
userName: string;
|
||||||
userProfilePicture: string;
|
userProfilePicture: string;
|
||||||
userLink: string;
|
userLink: string;
|
||||||
|
userRequestCount: number;
|
||||||
airDate?: string;
|
airDate?: string;
|
||||||
status: MediaRequestStatus;
|
status: MediaRequestStatus;
|
||||||
backdropPath: string;
|
backdropPath: string;
|
||||||
@@ -15,6 +16,15 @@ export type MediaRequest = {
|
|||||||
href: string;
|
href: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Users = {
|
||||||
|
app: string;
|
||||||
|
id: number;
|
||||||
|
userName: string;
|
||||||
|
userProfilePicture: string;
|
||||||
|
userLink: string;
|
||||||
|
userRequestCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export enum MediaRequestStatus {
|
export enum MediaRequestStatus {
|
||||||
PendingApproval = 1,
|
PendingApproval = 1,
|
||||||
Approved = 2,
|
Approved = 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user