feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

+124 -20
View File
@@ -1,7 +1,23 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
import { useEffect, useState } from "react";
import {
Badge,
Box,
Center,
Group,
Image,
Paper,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -12,14 +28,29 @@ import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
import type { MediaMissingTab } from "./tabs";
import { resolveMediaMissingTab } from "./tabs";
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
export default function MediaMissingWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
const isAdvanced = displayMode === "advanced";
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(options.showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
useEffect(() => {
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
}, [activeTab, selectedTab]);
if (!data) return <WidgetEmptyState />;
if (data.length === 0) throw new NoIntegrationDataError();
@@ -32,25 +63,26 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
</Center>
);
const missing = data.flatMap((entry) =>
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
);
const queued = data.flatMap((entry) =>
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
);
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
const isThin = width > 0 && width < 160;
const isShort = height > 0 && height < 180;
const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
const isShort = !isAdvanced && height > 0 && height < 180;
const targetCardWidth = isShort ? 130 : 200;
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
const tabLabel = (label: string, shown: number, total: number) =>
isThin ? total : `${label} (${shown}/${total})`;
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
const renderPanel = (
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
emptyLabel: string,
) => (
<ScrollArea h="100%" scrollbarSize={4}>
<Box p="xs">
{entries.length === 0 ? (
@@ -68,12 +100,60 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
</ScrollArea>
);
const partialFailures = failedIntegrations.length > 0 && (
<Group gap={4} p={4} wrap="wrap">
{failedIntegrations.map((entry) => (
<Tooltip key={entry.integrationId} label={entry.error} multiline maw={360}>
<Badge size="xs" color="red" variant="light">
{entry.integrationName}
</Badge>
</Tooltip>
))}
</Group>
);
if (isAdvanced) {
return (
<Stack h="100%" gap={0}>
{partialFailures}
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
{options.showMissing && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconQuestionMark size={16} />
<Text size="sm" fw={600}>
{tabLabel(t("tab.missing"), missing.length, missingCount)}
</Text>
</Group>
<Box h="calc(100% - 40px)">{renderPanel(missing, t("empty.missing"))}</Box>
</Paper>
)}
{options.showQueued && (
<Paper withBorder radius="sm" style={{ minHeight: 0, overflow: "hidden" }}>
<Group p="xs" gap="xs">
<IconDownload size={16} />
<Text size="sm" fw={600}>
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
</Text>
</Group>
<Box h="calc(100% - 40px)">{renderPanel(queued, t("empty.queued"))}</Box>
</Paper>
)}
</SimpleGrid>
</Stack>
);
}
return (
<Tabs
defaultValue={options.showMissing ? "missing" : "queued"}
value={activeTab}
onChange={(value) => {
if (value === "missing" || value === "queued") setSelectedTab(value);
}}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
{partialFailures}
<Tabs.List grow>
{options.showMissing && (
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
@@ -116,7 +196,14 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
}
return (
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
<ThemeIcon
className={classes.poster}
h={size}
w={w}
radius="sm"
variant="light"
color={type === "movie" ? "yellow" : "blue"}
>
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
</ThemeIcon>
);
@@ -183,8 +270,25 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
);
};
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
const CardShell = ({
item,
density,
children,
}: {
item: MissingMediaItem | QueuedMediaItem;
density: Density;
children: ReactNode;
}) => (
<Paper
className={classes.card}
component="a"
href={item.link}
target="_blank"
rel="noreferrer"
radius="sm"
p="xs"
h={CARD_HEIGHT[density]}
>
{item.imageUrl && (
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
)}