✨ 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",
|
||||
"replaceLinksWithExternalHost": {
|
||||
"label": "Replace links with external host"
|
||||
},
|
||||
"openInNewTab": {
|
||||
"label": "Open links in new tab"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user