feat: add media requests widget (#774)

Co-authored-by: SeDemal <Tagaishi@hotmail.ch>
Co-authored-by: SeDemal <demal.sebastien@bluewin.ch>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-08-24 15:23:16 +02:00
committed by GitHub
parent 7ec4adcb24
commit acbb834889
30 changed files with 1106 additions and 29 deletions

View File

@@ -0,0 +1,7 @@
.gridElement:not(:nth-child(8n)) {
border-right: 0.5cqmin solid var(--app-shell-border-color);
}
.gridElement:not(:nth-last-child(-n + 8)) {
border-bottom: 0.5cqmin solid var(--app-shell-border-color);
}

View File

@@ -0,0 +1,220 @@
import { useMemo } from "react";
import { ActionIcon, Avatar, Card, Center, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import type { Icon } from "@tabler/icons-react";
import {
IconDeviceTv,
IconExternalLink,
IconHourglass,
IconLoaderQuarter,
IconMovie,
IconPlayerPlay,
IconReceipt,
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { RequestStats } from "../../../../integrations/src/interfaces/media-requests/media-request";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
export default function MediaServerWidget({
integrationIds,
isEditMode,
serverData,
itemId,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const tCommon = useScopedI18n("common");
const isQueryEnabled = Boolean(itemId);
const { data: requestStats, isError: _isError } = clientApi.widget.mediaRequests.getStats.useQuery(
{
integrationIds,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemId!,
},
{
initialData: !serverData ? undefined : serverData.initialData,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
enabled: integrationIds.length > 0 && isQueryEnabled,
},
);
const { width, height, ref } = useElementSize();
const baseData = useMemo(
() => requestStats?.filter((group) => group != null).flatMap((group) => group.data) ?? [],
[requestStats],
);
const stats = useMemo(() => baseData.flatMap(({ stats }) => stats), [baseData]);
const users = useMemo(
() =>
baseData
.flatMap(({ integration, users }) =>
users.flatMap((user) => ({ ...user, appKind: integration.kind, appName: integration.name })),
)
.sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
[baseData],
);
if (integrationIds.length === 0)
return (
<Center ref={ref} h="100%">
{tCommon("errors.noIntegration")}
</Center>
);
if (users.length === 0 || stats.length === 0)
return (
<Center ref={ref} h="100%">
{tCommon("errors.noData")}
</Center>
);
//Add processing and available
const data = [
{
name: "approved",
icon: IconThumbUp,
number: stats.reduce((count, { approved }) => count + approved, 0),
},
{
name: "pending",
icon: IconHourglass,
number: stats.reduce((count, { pending }) => count + pending, 0),
},
{
name: "processing",
icon: IconLoaderQuarter,
number: stats.reduce((count, { processing }) => count + processing, 0),
},
{
name: "declined",
icon: IconThumbDown,
number: stats.reduce((count, { declined }) => count + declined, 0),
},
{
name: "available",
icon: IconPlayerPlay,
number: stats.reduce((count, { available }) => count + available, 0),
},
{
name: "tv",
icon: IconDeviceTv,
number: stats.reduce((count, { tv }) => count + tv, 0),
},
{
name: "movie",
icon: IconMovie,
number: stats.reduce((count, { movie }) => count + movie, 0),
},
{
name: "total",
icon: IconReceipt,
number: stats.reduce((count, { total }) => count + total, 0),
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
return (
<Stack
className="mediaRequests-stats-layout"
display="flex"
h="100%"
gap="2cqmin"
p="2cqmin"
align="center"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Text className="mediaRequests-stats-stats-title" size="6.5cqmin">
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gutter={0} w="100%">
{data.map((stat) => (
<Grid.Col
className={combineClasses(
classes.gridElement,
"mediaRequests-stats-stat-wrapper",
`mediaRequests-stats-stat-${stat.name}`,
)}
key={stat.name}
span={3}
>
<Tooltip label={t(`titles.stats.${stat.name}`)}>
<Stack className="mediaRequests-stats-stat-stack" align="center" gap="2cqmin" p="2cqmin">
<stat.icon className="mediaRequests-stats-stat-icon" size="7.5cqmin" />
<Text className="mediaRequests-stats-stat-value" size="5cqmin">
{stat.number}
</Text>
</Stack>
</Tooltip>
</Grid.Col>
))}
</Grid>
<Text className="mediaRequests-stats-users-title" size="6.5cqmin">
{t("titles.users.main")}
</Text>
<Stack
className="mediaRequests-stats-users-wrapper"
flex={1}
w="100%"
ref={ref}
display="flex"
gap="2cqmin"
style={{ overflow: "hidden" }}
>
{users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
<Card
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
)}
key={user.id}
withBorder
p="2cqmin"
flex={1}
mah="38.5cqmin"
radius="2.5cqmin"
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
<Tooltip label={user.appName}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size="12.5cqmin"
src={user.avatar}
bd={`0.5cqmin solid ${user.appKind === "overseerr" ? "#ECB000" : "#6677CC"}`}
/>
</Tooltip>
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">
<Text className="mediaRequests-stats-users-user-userName" size="6cqmin">
{user.displayName}
</Text>
<Text className="mediaRequests-stats-users-user-request-count" size="4cqmin">
{tCommon("rtl", { value: t("titles.users.requests"), symbol: tCommon("symbols.colon") }) +
user.requestCount}
</Text>
</Stack>
<Space flex={1} />
<ActionIcon
className="mediaRequests-stats-users-user-link-button"
variant="light"
color="var(--mantine-color-text)"
size="10cqmin"
component="a"
href={user.link}
>
<IconExternalLink className="mediaRequests-stats-users-user-link-icon" size="7.5cqmin" />
</ActionIcon>
</Group>
</Card>
))}
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,11 @@
import { IconChartBar } from "@tabler/icons-react";
import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
options: {},
supportedIntegrations: ["overseerr", "jellyseerr"],
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,25 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../../definition";
export default async function getServerDataAsync({
integrationIds,
itemId,
}: WidgetProps<"mediaRequests-requestStats">) {
if (integrationIds.length === 0 || !itemId) {
return {
initialData: undefined,
};
}
const stats = await api.widget.mediaRequests.getStats({
integrationIds,
itemId,
});
return {
initialData: stats.filter((group) => group != null),
};
}