feat: modernize dashboard widgets
This commit is contained in:
1 parent
f3048f4804
commit
a2763d1f6f
261 files changed
+9392
-2697
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
@@ -15,34 +16,57 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
import { getQueryKey } from "@trpc/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { useWidgetRuntimeQueries } from "../runtime-hooks";
|
||||
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,
|
||||
displayMode,
|
||||
widgetRuntimeRef,
|
||||
}: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const pageSize = Number(options.pageSize);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
||||
{ integrationIds, pageSize },
|
||||
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
|
||||
);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const showMissing = isAdvanced || options.showMissing;
|
||||
const showQueued = isAdvanced || options.showQueued;
|
||||
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
|
||||
const input = { integrationIds, pageSize };
|
||||
useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]);
|
||||
const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
|
||||
staleTime: 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const data = getUsableWidgetQueryData(mediaQuery);
|
||||
const [selectedTab, setSelectedTab] = useState<MediaMissingTab>(showMissing ? "missing" : "queued");
|
||||
const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
|
||||
}, [activeTab, selectedTab]);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
if (data.length === 0) throw new NoIntegrationDataError();
|
||||
if (!options.showMissing && !options.showQueued)
|
||||
if (!showMissing && !showQueued)
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
@@ -55,12 +79,15 @@ export default function MediaMissingWidget({
|
||||
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(showMissing) + Number(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})`);
|
||||
|
||||
@@ -77,7 +104,12 @@ export default function MediaMissingWidget({
|
||||
) : (
|
||||
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
|
||||
{entries.map(({ item, integrationId }) => (
|
||||
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
|
||||
<MediaCard
|
||||
key={`${integrationId}-${item.type}-${item.id}`}
|
||||
item={item}
|
||||
density={density}
|
||||
showQueueDetails={isAdvanced}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -85,31 +117,86 @@ export default function MediaMissingWidget({
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
const partialFailures = failedIntegrations.length > 0 && (
|
||||
<Group gap={4} p={4} wrap="wrap">
|
||||
{failedIntegrations.map((entry) => (
|
||||
<Tooltip key={entry.integrationId} label={`${entry.integrationName}: ${t("name")}`}>
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{entry.integrationName}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
const queryFailure = <WidgetQueryErrorIndicator error={mediaQuery.error} label={t("name")} />;
|
||||
|
||||
if (isAdvanced) {
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
<SimpleGrid cols={enabledPanelCount} spacing="sm" p="sm" style={{ flex: 1, minHeight: 0 }}>
|
||||
{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>
|
||||
)}
|
||||
{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" }}
|
||||
>
|
||||
<Group justify="flex-end" px="xs">
|
||||
{queryFailure}
|
||||
{partialFailures}
|
||||
</Group>
|
||||
<Tabs.List grow>
|
||||
{options.showMissing && (
|
||||
{showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
{showQueued && (
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
{options.showMissing && (
|
||||
{showMissing && (
|
||||
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(missing, t("empty.missing"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
{showQueued && (
|
||||
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(queued, t("empty.queued"))}
|
||||
</Tabs.Panel>
|
||||
@@ -215,25 +302,33 @@ const CardShell = ({
|
||||
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 />
|
||||
)}
|
||||
<div className={classes.content}>{children}</div>
|
||||
</Paper>
|
||||
);
|
||||
}) => {
|
||||
const href = getSafeApplicationUrl(item.link);
|
||||
return (
|
||||
<Paper
|
||||
className={classes.card}
|
||||
component={href ? "a" : "div"}
|
||||
href={href}
|
||||
target={href ? "_blank" : undefined}
|
||||
rel={href ? SAFE_NEW_TAB_REL : undefined}
|
||||
radius="sm"
|
||||
p="xs"
|
||||
h={CARD_HEIGHT[density]}
|
||||
>
|
||||
<div className={classes.content}>{children}</div>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
|
||||
const MediaCard = ({
|
||||
item,
|
||||
density,
|
||||
showQueueDetails,
|
||||
}: {
|
||||
item: MissingMediaItem | QueuedMediaItem;
|
||||
density: Density;
|
||||
showQueueDetails: boolean;
|
||||
}) => {
|
||||
const isQueued = "percentComplete" in item;
|
||||
|
||||
return (
|
||||
@@ -250,6 +345,11 @@ const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem
|
||||
{item.type === "episode" ? item.title : item.year}
|
||||
</Text>
|
||||
)}
|
||||
{isQueued && showQueueDetails && (
|
||||
<Text fz="10px" c="dimmed" lineClamp={1} lh={1.1}>
|
||||
{[item.status, item.timeLeft].filter(Boolean).join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveMediaMissingTab } from "./tabs";
|
||||
|
||||
describe("resolveMediaMissingTab", () => {
|
||||
test("preserves the selected tab while it remains enabled", () => {
|
||||
expect(resolveMediaMissingTab("queued", true, true)).toBe("queued");
|
||||
});
|
||||
|
||||
test("falls back when the selected tab is disabled", () => {
|
||||
expect(resolveMediaMissingTab("missing", false, true)).toBe("queued");
|
||||
expect(resolveMediaMissingTab("queued", true, false)).toBe("missing");
|
||||
});
|
||||
|
||||
test("returns no tab when both panels are disabled", () => {
|
||||
expect(resolveMediaMissingTab("missing", false, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
export type MediaMissingTab = "missing" | "queued";
|
||||
|
||||
export const resolveMediaMissingTab = (
|
||||
current: MediaMissingTab,
|
||||
showMissing: boolean,
|
||||
showQueued: boolean,
|
||||
): MediaMissingTab | null => {
|
||||
if (current === "missing" && showMissing) return current;
|
||||
if (current === "queued" && showQueued) return current;
|
||||
if (showMissing) return "missing";
|
||||
if (showQueued) return "queued";
|
||||
return null;
|
||||
};
|
||||
Reference in new issue
Block a user