Merge remote-tracking branch 'origin/feat/widget-modernization' into feat/stack-agent-widget-fixes-mobile

This commit is contained in:
Thomas Camlong committed 2026-08-10 09:40:29 +02:00
commit 4822c2fa12
7 files changed
+409 -184

No files matched your search

@@ -1,28 +1,38 @@
.beszelStatsSystemToggle {
position: absolute;
top: 6px;
left: 6px;
z-index: 1;
.beszelStatsControls {
opacity: 0;
transition: opacity 0.15s ease;
transition: opacity 150ms ease-out;
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsSystemToggle,
.beszelStatsContainer:focus-within .beszelStatsSystemToggle {
.beszelStatsContainer:hover .beszelStatsControls,
.beszelStatsContainer:focus-within .beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
.beszelStatsSystemToggle {
max-width: 50%;
flex-shrink: 0;
}
.beszelStatsPeriodToggle {
flex: 1;
min-width: 0;
}
.beszelStatsPeriodButton {
flex: 1;
}
@media (any-pointer: coarse) {
.beszelStatsSystemToggle {
.beszelStatsControls {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.beszelStatsSystemToggle {
.beszelStatsControls {
transition: none;
}
}
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { Box, Button, Center, Group, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
@@ -185,54 +185,65 @@ export default function BeszelSystemStatsWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap="md" p="sm">
{!isEditMode && systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle}
{!isEditMode && (
<Group gap="xs" wrap="nowrap" className={classes.beszelStatsControls}>
{systems.length > 1 && (
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
<Menu.Target>
<Button
variant="default"
size="compact-xs"
leftSection={<IconServer size={14} />}
className={classes.beszelStatsSystemToggle}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
{width >= 560 ? (
<Button.Group className={classes.beszelStatsPeriodToggle}>
{periodOptions.map((period) => (
<Button
key={period.value}
size="compact-xs"
variant={period.value === options.timePeriod ? "filled" : "default"}
className={classes.beszelStatsPeriodButton}
aria-pressed={period.value === options.timePeriod}
disabled={isSelectionSavePending}
onClick={() => handleTimePeriod(period.value)}
>
{period.label}
</Button>
))}
</Button.Group>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
>
{selectedLabel}
</Button>
</Menu.Target>
<Menu.Dropdown>
{systems.map((system) => (
<Menu.Item
key={system.value}
fz="xs"
fw={system.value === selectedValue ? 600 : 400}
c={system.value !== selectedValue ? "dimmed" : undefined}
disabled={isSelectionSavePending}
onClick={() => handleSelectSystem(system.value)}
>
{system.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
data={periodOptions}
className={classes.beszelStatsPeriodToggle}
/>
)}
</Group>
)}
{!isEditMode &&
(width >= 560 ? (
<SegmentedControl
size="xs"
fullWidth
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={periodOptions}
/>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
/>
))}
<BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
@@ -1,3 +1,23 @@
.root {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.tablePaper {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.tableContainer {
flex: 1;
min-height: 0;
}
.sessionRow {
cursor: pointer;
@@ -11,3 +31,16 @@
outline-offset: -2px;
}
}
.detailsTarget {
width: 100%;
min-width: 0;
color: inherit;
text-align: left;
border-radius: var(--mantine-radius-xs);
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
}
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { StreamSession } from "@homarr/integrations";
import { SessionDetailsPopover } from "./component";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
const session = {
sessionId: "session-1",
sessionName: "Living room",
user: { username: "Alex", profilePictureUrl: null },
currentlyPlaying: {
type: "movie",
name: "Example movie",
episodeName: null,
seasonName: null,
metadata: null,
},
} as unknown as StreamSession;
function ControlledPopover({ children }: { children: ReactNode }) {
const [opened, setOpened] = useState(false);
return (
<SessionDetailsPopover item={session} opened={opened} onChange={setOpened}>
{children}
</SessionDetailsPopover>
);
}
describe("SessionDetailsPopover", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
vi.stubGlobal("matchMedia", () => ({
matches: false,
media: "",
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
test("opens and closes anchored session details from the target", async () => {
await act(() =>
root.render(
<MantineProvider env="test">
<ControlledPopover>Open session</ControlledPopover>
</MantineProvider>,
),
);
const target = host.querySelector("button");
expect(target?.getAttribute("aria-expanded")).toBe("false");
await act(async () => {
target?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(target?.getAttribute("aria-expanded")).toBe("true");
expect(document.body.textContent).toContain("Example movie");
await act(async () => {
target?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(target?.getAttribute("aria-expanded")).toBe("false");
});
});
+195 -125
View File
@@ -1,8 +1,19 @@
"use client";
import type { ReactNode } from "react";
import { Fragment, useMemo } from "react";
import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core";
import { useMemo, useState } from "react";
import {
Avatar,
Badge,
Divider,
Group,
Popover,
Progress,
SimpleGrid,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import {
IconDeviceTv,
IconHeadphones,
@@ -19,7 +30,6 @@ import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
@@ -74,6 +84,7 @@ export default function MediaServerWidget({
const t = useScopedI18n("widget.mediaServer");
const isAdvanced = displayMode === "advanced";
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
...(width >= 300 || isAdvanced
@@ -108,55 +119,14 @@ export default function MediaServerWidget({
header: t("items.currentlyPlaying"),
Cell: ({ row }) => {
const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null;
const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
positionMs !== null && durationMs !== null
? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
: null;
return (
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon
size={16}
color={isPaused ? "var(--mantine-color-yellow-6)" : undefined}
style={{ flexShrink: 0 }}
/>
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused && (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
)}
{!isPaused && remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)}
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "var(--mantine-color-default-border)" }}
/>
)}
</Stack>
<SessionDetailsPopover
item={row.original}
opened={selectedRowId === row.id}
onChange={(opened) => setSelectedRowId(opened ? row.id : null)}
>
<CurrentlyPlaying item={row.original} />
</SessionDetailsPopover>
);
},
} satisfies MRT_ColumnDef<StreamSession>,
@@ -205,7 +175,7 @@ export default function MediaServerWidget({
: []),
];
return result;
}, [isAdvanced, options.showBitrate, options.showLocation, t, width]);
}, [isAdvanced, options.showBitrate, options.showLocation, selectedRowId, t, width]);
// Only render the flat list of sessions when the currentStreams change
// Otherwise it will always create a new array reference and cause the table to re-render
@@ -223,7 +193,6 @@ export default function MediaServerWidget({
[currentStreams],
);
const { openModal } = useModalAction(ItemInfoModal);
const table = useTranslatedMantineReactTable({
columns,
data: flatSessions,
@@ -240,12 +209,9 @@ export default function MediaServerWidget({
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
enableColumnPinning: true,
enableColumnPinning: false,
initialState: {
density: "xs",
columnPinning: {
right: ["currentlyPlaying", "status"],
},
},
mantineTableHeadProps: {
fz: "xs",
@@ -257,6 +223,7 @@ export default function MediaServerWidget({
flex: 1,
withBorder: false,
shadow: undefined,
className: classes.tablePaper,
},
mantineTableProps: {
className: "media-server-widget-table",
@@ -265,27 +232,28 @@ export default function MediaServerWidget({
},
},
mantineTableContainerProps: {
className: classes.tableContainer,
style: {
height: "100%",
pointerEvents: isEditMode ? "none" : undefined,
},
},
mantineTableBodyRowProps: ({ row }) => {
const openDetails = () => {
openModal({ item: row.original }, { title: row.original.sessionName });
};
const toggleDetails = () => setSelectedRowId((current) => (current === row.id ? null : row.id));
return {
className: isEditMode ? undefined : classes.sessionRow,
tabIndex: isEditMode ? -1 : 0,
"aria-label": row.original.sessionName,
onClick: isEditMode ? undefined : openDetails,
"aria-haspopup": isEditMode ? undefined : "dialog",
"aria-expanded": isEditMode ? undefined : selectedRowId === row.id,
onClick: isEditMode ? undefined : toggleDetails,
onKeyDown: isEditMode
? undefined
: (event) => {
if (event.currentTarget !== event.target) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openDetails();
toggleDetails();
},
};
},
@@ -321,7 +289,7 @@ export default function MediaServerWidget({
const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
return (
<Stack gap={0} h="100%" display="flex">
<Stack className={classes.root} gap={0} h="100%" display="flex">
{isAdvanced && (
<Group px="xs" py={4} gap="xs">
<Badge variant="light">{t("summary.sessions", { count: flatSessions.length })}</Badge>
@@ -384,89 +352,191 @@ export default function MediaServerWidget({
);
}
const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => {
const t = useScopedI18n("widget.mediaServer.items");
const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null;
function CurrentlyPlaying({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer");
const currentlyPlaying = item.currentlyPlaying;
if (!currentlyPlaying) {
return (
<Text size="xs" c="dimmed">
{item.sessionName}
</Text>
);
}
const metadata = useMemo(() => {
return innerProps.item.currentlyPlaying?.metadata
? constructMetadata(innerProps.item.currentlyPlaying.metadata)
const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
}, [innerProps.item.currentlyPlaying?.metadata]);
const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
return (
<Stack align="center">
<Flex direction="column" gap="xs" align="center">
{Icon && innerProps.item.currentlyPlaying !== null && (
<Group gap="sm" align="center">
<Icon size={24} />
<Title order={2}>{innerProps.item.currentlyPlaying.name}</Title>
</Group>
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0 }}>
<Icon size={16} color={isPaused ? "var(--mantine-color-yellow-6)" : undefined} style={{ flexShrink: 0 }} />
<Text size="xs" lineClamp={1} style={{ minWidth: 0 }}>
{currentlyPlaying.name}
</Text>
{isPaused ? (
<Text size="xs" c="yellow" style={{ flexShrink: 0 }}>
{t("items.paused")}
</Text>
) : (
remainingMinutes !== null && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{t("items.remaining", { minutes: remainingMinutes.toString() })}
</Text>
)
)}
{innerProps.item.currentlyPlaying?.episodeName && (
<Group>
<Title order={4}>{innerProps.item.currentlyPlaying.episodeName}</Title>
{innerProps.item.currentlyPlaying.seasonName && (
<>
{" - "}
<Title order={4}>{innerProps.item.currentlyPlaying.seasonName}</Title>
</>
</Group>
{progressPercent !== null && (
<Progress
value={progressPercent}
size={4}
color={isPaused ? "yellow" : "green"}
style={{ backgroundColor: "var(--mantine-color-default-border)" }}
/>
)}
</Stack>
);
}
export function SessionDetailsPopover({
item,
opened,
onChange,
children,
}: {
item: StreamSession;
opened: boolean;
onChange: (opened: boolean) => void;
children: ReactNode;
}) {
return (
<Popover
opened={opened}
onChange={onChange}
position="bottom-start"
width="min(26.25rem, calc(100vw - 1.5rem))"
shadow="md"
withArrow
withinPortal
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.detailsTarget}
aria-label={item.sessionName}
onClick={(event) => {
event.stopPropagation();
onChange(!opened);
}}
>
{children}
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<SessionDetails item={item} />
</Popover.Dropdown>
</Popover>
);
}
function SessionDetails({ item }: { item: StreamSession }) {
const t = useScopedI18n("widget.mediaServer.items");
const Icon = item.currentlyPlaying ? mediaTypeIconMap[item.currentlyPlaying.type] : null;
const metadata = item.currentlyPlaying?.metadata ? constructMetadata(item.currentlyPlaying.metadata) : null;
return (
<Stack gap="sm" style={{ minWidth: 0 }}>
{Icon && item.currentlyPlaying && (
<Group gap="sm" align="flex-start" wrap="nowrap">
<Icon size={22} style={{ flexShrink: 0 }} />
<Stack gap={2} style={{ minWidth: 0 }}>
<Text fw={600} lineClamp={2}>
{item.currentlyPlaying.name}
</Text>
{item.currentlyPlaying.episodeName && (
<Text size="xs" c="dimmed" lineClamp={2}>
{[item.currentlyPlaying.episodeName, item.currentlyPlaying.seasonName].filter(Boolean).join(" · ")}
</Text>
)}
</Group>
)}
</Flex>
</Stack>
</Group>
)}
<NormalizedLine
itemKey={t("user")}
value={
<Group gap="sm" align="center">
<Avatar size="sm" src={innerProps.item.user?.profilePictureUrl} />{" "}
<Text>{innerProps.item.user?.username ?? t("unknownUser")}</Text>
<Group gap="xs" align="center" wrap="nowrap">
<Avatar size="xs" src={item.user?.profilePictureUrl} />
<Text size="sm" truncate>
{item.user?.username ?? t("unknownUser")}
</Text>
</Group>
}
/>
<NormalizedLine itemKey={t("name")} value={<Text>{innerProps.item.sessionName}</Text>} />
<NormalizedLine itemKey={t("id")} value={<Text>{innerProps.item.sessionId}</Text>} />
<NormalizedLine
itemKey={t("name")}
value={
<Text size="sm" truncate>
{item.sessionName}
</Text>
}
/>
<NormalizedLine
itemKey={t("id")}
value={
<Text size="xs" ff="monospace" truncate>
{item.sessionId}
</Text>
}
/>
{metadata ? (
<Stack w="100%" gap={0}>
<Divider label={t("metadata.title")} labelPosition="center" mt="lg" mb="sm" />
<Group align="flex-start">
{objectEntries(metadata).map(([key, value], index) => (
<Fragment key={key}>
{index !== 0 && <Divider key={index} orientation="vertical" />}
<Stack gap={4}>
<Text fw="bold">{t(`metadata.${key}.title`)}</Text>
{Object.entries(value)
.filter(([_, value]) => Boolean(value))
.map(([innerKey, value]) => (
<Group justify="space-between" w="100%" key={innerKey} wrap="nowrap">
<Text>{t(`metadata.${key}.${innerKey}` as never)}</Text>
<Text>{value}</Text>
</Group>
))}
</Stack>
</Fragment>
<Stack gap="xs">
<Divider label={t("metadata.title")} labelPosition="left" />
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="sm" verticalSpacing="xs">
{objectEntries(metadata).map(([key, entries]) => (
<Stack key={key} gap={4} style={{ minWidth: 0 }}>
<Text fw={600} size="xs">
{t(`metadata.${key}.title`)}
</Text>
{Object.entries(entries)
.filter(([_, entryValue]) => Boolean(entryValue))
.map(([innerKey, entryValue]) => (
<Group justify="space-between" gap="xs" key={innerKey} wrap="nowrap">
<Text size="xs" c="dimmed">
{t(`metadata.${key}.${innerKey}` as never)}
</Text>
<Text size="xs" ta="right" truncate>
{entryValue}
</Text>
</Group>
))}
</Stack>
))}
</Group>
</SimpleGrid>
</Stack>
) : null}
</Stack>
);
}).withOptions({
defaultTitle() {
return "";
},
size: "lg",
centered: true,
});
}
const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => {
return (
<Group w="100%" align="top" justify="space-between">
<Text>{itemKey}:</Text>
{value}
<Group w="100%" gap="md" align="flex-start" justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ flexShrink: 0 }}>
{itemKey}:
</Text>
<Stack gap={0} align="flex-end" style={{ minWidth: 0 }}>
{value}
</Stack>
</Group>
);
};