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:
Tagaishi
2023-09-01 22:15:40 +02:00
committed by GitHub
parent 1bb1a8f628
commit 371587c62d
7 changed files with 368 additions and 188 deletions

View File

@@ -6,6 +6,9 @@
"title": "Media requests list",
"replaceLinksWithExternalHost": {
"label": "Replace links with external host"
},
"openInNewTab": {
"label": "Open links in new tab"
}
}
},

View File

@@ -4,18 +4,24 @@
"description": "Statistics about your media requests",
"settings": {
"title": "Media requests stats",
"direction": {
"label": "Direction of the layout.",
"data":{
"row": "Horizontal",
"column": "Vertical"
}
"replaceLinksWithExternalHost": {
"label": "Replace links with external host"
},
"openInNewTab": {
"label": "Open links in new tab"
}
}
},
"stats": {
"mediaStats": {
"title": "Media Stats",
"pending": "Pending approvals",
"tvRequests": "TV requests",
"movieRequests": "Movie requests"
"movieRequests": "Movie requests",
"approved": "Already approved",
"totalRequests": "Total"
},
"userStats": {
"title": "Top Users",
"requests": "Requests: {{number}}"
}
}

View File

@@ -3,15 +3,17 @@ import { z } from 'zod';
import { checkIntegrationsType } from '~/tools/client/app-properties';
import { getConfig } from '~/tools/config/getConfig';
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 { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile';
export const mediaRequestsRouter = createTRPCRouter({
all: publicProcedure
allMedia: publicProcedure
.input(
z.object({
configName: z.string(),
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
})
)
.query(async ({ input }) => {
@@ -21,8 +23,6 @@ export const mediaRequestsRouter = createTRPCRouter({
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
);
Consola.log(`Retrieving media requests from ${apps.length} apps`);
const promises = apps.map((app): Promise<MediaRequest[]> => {
const apiKey =
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
@@ -32,14 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.then(async (response) => {
const body = (await response.json()) as OverseerrResponse;
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as
| MediaRequestListWidget
| undefined;
if (!mediaWidget) {
Consola.log('No media-requests-list found');
return Promise.resolve([]);
}
const appUrl = mediaWidget.properties.replaceLinksWithExternalHost
const appUrl = input.widget.properties.replaceLinksWithExternalHost
? app.behaviour.externalUrl
: app.url;
@@ -59,8 +52,9 @@ export const mediaRequestsRouter = createTRPCRouter({
type: item.type,
name: genericItem.name,
userName: item.requestedBy.displayName,
userProfilePicture: constructAvatarUrl(appUrl, item),
userProfilePicture: constructAvatarUrl(appUrl, item.requestedBy.avatar),
userLink: `${appUrl}/users/${item.requestedBy.id}`,
userRequestCount: item.requestedBy.requestCount,
airDate: genericItem.airDate,
status: item.status,
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
@@ -85,17 +79,66 @@ export const mediaRequestsRouter = createTRPCRouter({
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 isAbsolute =
item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://');
const constructAvatarUrl = (appUrl: string, avatar: string) => {
const isAbsolute = avatar.startsWith('http://') || avatar.startsWith('https://');
if (isAbsolute) {
return item.requestedBy.avatar;
return avatar;
}
return `${appUrl}/${item.requestedBy.avatar}`;
return `${appUrl}/${avatar}`;
};
const retrieveDetailsForItem = async (
@@ -117,7 +160,7 @@ const retrieveDetailsForItem = async (
backdropPath: series.backdropPath,
posterPath: series.backdropPath,
};
}
};
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
headers,
@@ -158,6 +201,10 @@ type OverseerrResponse = {
results: OverseerrResponseItem[];
};
type OverseerrUsers = {
results: OverseerrResponseItemUser[];
};
type OverseerrResponseItem = {
id: number;
status: number;
@@ -176,4 +223,5 @@ type OverseerrResponseItemUser = {
id: number;
displayName: string;
avatar: string;
requestCount: number;
};

View File

@@ -6,6 +6,7 @@ import {
Flex,
Group,
Image,
ScrollArea,
Stack,
Text,
Tooltip,
@@ -30,6 +31,10 @@ const definition = defineWidget({
type: 'switch',
defaultValue: true,
},
openInNewTab: {
type: 'switch',
defaultValue: true,
},
},
component: MediaRequestListTile,
gridstack: {
@@ -55,7 +60,8 @@ const useMediaRequestDecisionMutation = () => {
const utils = api.useContext();
const { mutateAsync } = api.overseerr.decide.useMutation({
onSuccess() {
utils.mediaRequest.all.invalidate();
utils.mediaRequest.allMedia.invalidate();
utils.mediaRequest.users.invalidate();
},
});
const { t } = useTranslation('modules/media-requests-list');
@@ -93,7 +99,7 @@ const useMediaRequestDecisionMutation = () => {
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
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
const decideAsync = useMediaRequestDecisionMutation();
@@ -125,115 +131,118 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
});
return (
<Stack>
{countPendingApproval > 0 ? (
<Text>{t('pending', { countPendingApproval })}</Text>
) : (
<Text>{t('nonePending')}</Text>
)}
{sortedData.map((item) => (
<Card withBorder>
<Flex wrap="wrap" justify="space-between" gap="md">
<Flex gap="md">
<Image
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">
<ScrollArea h="100%">
<Stack>
{countPendingApproval > 0 ? (
<Text>{t('pending', { countPendingApproval })}</Text>
) : (
<Text>{t('nonePending')}</Text>
)}
{sortedData.map((item) => (
<Card radius="md" withBorder>
<Flex wrap="wrap" justify="space-between" gap="md">
<Flex gap="md">
<Image
src={item.userProfilePicture}
width={25}
height={25}
alt="requester avatar"
radius="xl"
src={item.posterPath}
width={30}
height={50}
alt="poster"
radius="xs"
withPlaceholder
/>
<Text
component="a"
href={item.userLink}
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
>
{item.userName}
</Text>
<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
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 && (
<Group>
<Tooltip label={t('tooltips.approve')} withArrow withinPortal>
<ActionIcon
variant="light"
color="green"
onClick={async () => {
notifications.show({
id: `approve ${item.id}`,
color: 'yellow',
title: t('tooltips.approving'),
message: undefined,
loading: true,
});
{item.status === MediaRequestStatus.PendingApproval && (
<Group>
<Tooltip label={t('tooltips.approve')} withArrow withinPortal>
<ActionIcon
variant="light"
color="green"
onClick={async () => {
notifications.show({
id: `approve ${item.id}`,
color: 'yellow',
title: t('tooltips.approving'),
message: undefined,
loading: true,
});
await decideAsync({
request: item,
isApproved: true,
});
}}
>
<IconThumbUp />
</ActionIcon>
</Tooltip>
<Tooltip label={t('tooltips.decline')} withArrow withinPortal>
<ActionIcon
variant="light"
color="red"
onClick={async () => {
await decideAsync({
request: item,
isApproved: false,
});
}}
>
<IconThumbDown />
</ActionIcon>
</Tooltip>
</Group>
)}
</Stack>
</Flex>
await decideAsync({
request: item,
isApproved: true,
});
}}
>
<IconThumbUp />
</ActionIcon>
</Tooltip>
<Tooltip label={t('tooltips.decline')} withArrow withinPortal>
<ActionIcon
variant="light"
color="red"
onClick={async () => {
await decideAsync({
request: item,
isApproved: false,
});
}}
>
<IconThumbDown />
</ActionIcon>
</Tooltip>
</Group>
)}
</Stack>
</Flex>
<Image
src={item.backdropPath}
pos="absolute"
w="100%"
h="100%"
opacity={0.1}
top={0}
left={0}
style={{ pointerEvents: 'none' }}
/>
</Card>
))}
</Stack>
<Image
src={item.backdropPath}
pos="absolute"
w="100%"
h="100%"
opacity={0.1}
top={0}
left={0}
style={{ pointerEvents: 'none' }}
/>
</Card>
))}
</Stack>
</ScrollArea>
);
}

View File

@@ -1,29 +1,40 @@
import { Card, Flex, Stack, Text } from '@mantine/core';
import { IconChartBar } from '@tabler/icons-react';
import {
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 { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { useMediaRequestQuery } from './media-request-query';
import { useMediaRequestQuery, useUsersQuery } from './media-request-query';
import { MediaRequestStatus } from './media-request-types';
const definition = defineWidget({
id: 'media-requests-stats',
icon: IconChartBar,
options: {
direction: {
type: 'select',
defaultValue: 'row' as 'row' | 'column',
data: [
{ value: 'row' },
{ value: 'column' },
],
replaceLinksWithExternalHost: {
type: 'switch',
defaultValue: false,
},
openInNewTab: {
type: 'switch',
defaultValue: true,
},
},
gridstack: {
minWidth: 1,
minHeight: 1,
minWidth: 2,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
@@ -38,53 +49,134 @@ interface MediaRequestStatsWidgetProps {
function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) {
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) {
return <WidgetLoading />;
if (!mediaData || !usersData || mediaLoading || usersLoading) {
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 (
<Flex
w="100%"
h="100%"
gap="md"
direction={ widget.properties.direction?? 'row' }
>
<StatCard
number={data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length}
label={t('stats.pending')}
/>
<StatCard
number={data.filter((x) => x.type === 'tv').length}
label={t('stats.tvRequests')}
/>
<StatCard
number={data.filter((x) => x.type === 'movie').length}
label={t('stats.movieRequests')}
/>
<Flex h="100%" gap={0} direction="column">
<Text mt={-5}>{t('mediaStats.title')}</Text>
<Card py={5} px={10} radius="md" style={{ overflow: 'unset' }} withBorder>
{baseStats.map((stat, index) => {
return (
<Group key={index} position="apart">
<Text color="dimmed" align="center" size="xs">
{stat.label}
</Text>
<Text align="center" size="xs">
{stat.number}
</Text>
</Group>
);
})}
</Card>
<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>
);
}
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;

View File

@@ -1,10 +1,22 @@
import { useConfigContext } from '~/config/provider';
import { MediaRequestListWidget } from './MediaRequestListTile';
import { MediaRequestStatsWidget } from './MediaRequestStatsTile';
import { api } from '~/utils/api';
export const useMediaRequestQuery = () => {
export const useMediaRequestQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => {
const { name: configName } = useConfigContext();
return api.mediaRequest.all.useQuery(
{ configName: configName! },
return api.mediaRequest.allMedia.useQuery(
{ 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,
}

View File

@@ -8,6 +8,7 @@ export type MediaRequest = {
userName: string;
userProfilePicture: string;
userLink: string;
userRequestCount: number;
airDate?: string;
status: MediaRequestStatus;
backdropPath: string;
@@ -15,6 +16,15 @@ export type MediaRequest = {
href: string;
};
export type Users = {
app: string;
id: number;
userName: string;
userProfilePicture: string;
userLink: string;
userRequestCount: number;
};
export enum MediaRequestStatus {
PendingApproval = 1,
Approved = 2,