feat: modernize widget experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:48 +02:00
1 parent 6331c88911
commit 1fa345edd1
165 files changed
+3698 -1649

No files matched your search

@@ -2,7 +2,9 @@
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { getQueryKey } from "@trpc/react-query";
import {
Box,
Center,
Divider,
Group,
@@ -24,7 +26,9 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
@@ -46,7 +50,7 @@ export default function MediaTranscodingWidget({
width,
height,
displayMode,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"mediaTranscoding">) {
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
@@ -61,8 +65,11 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: requestPagination.page,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
useWidgetRuntimeQueries(widgetRuntimeRef, [
getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"),
]);
const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const transcodingData = getUsableWidgetQueryData(transcodingQuery);
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
@@ -89,10 +96,17 @@ export default function MediaTranscodingWidget({
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && (width < 280 || height < 140);
const footerLayout = getTranscodingFooterLayout(width, height);
const queryIndicator = (
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
<WidgetQueryErrorIndicator error={transcodingQuery.error} label={t("name")} />
</Box>
);
if (isAdvanced) {
return (
<Stack gap="xs" h="100%" p="xs">
<Stack gap="xs" h="100%" p="xs" pos="relative">
{queryIndicator}
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={width >= 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
<AdvancedPanel title={t("tab.workers")} icon={IconCpu2}>
@@ -106,8 +120,15 @@ export default function MediaTranscodingWidget({
</AdvancedPanel>
</SimpleGrid>
</ScrollArea>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Pagination total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs" />
<Group gap="xs" justify={queuePageCount > 1 ? "space-between" : "flex-end"} wrap="nowrap">
{queuePageCount > 1 && (
<QueuePaginationControls
total={queuePageCount}
value={queuePage}
onChange={handleQueuePageChange}
layout={footerLayout}
/>
)}
<HealthCheckStatus statistics={transcodingData.data.statistics} />
</Group>
</Stack>
@@ -115,7 +136,8 @@ export default function MediaTranscodingWidget({
}
return (
<Stack gap={4} h="100%">
<Stack gap={4} h="100%" pos="relative">
{queryIndicator}
{view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? (
@@ -124,7 +146,7 @@ export default function MediaTranscodingWidget({
<StatisticsPanel statistics={transcodingData.data.statistics} />
)}
<Divider />
<Group gap="xs" mb={4} ms={4} me={8}>
<Group gap="xs" mb={4} ms={4} me={8} wrap="nowrap">
<SegmentedControl
data={views.map((value) => {
const Icon = viewIcons[value];
@@ -132,12 +154,12 @@ export default function MediaTranscodingWidget({
label: (
<Center style={{ gap: 4 }}>
<Icon size={12} />
{isTiny ? (
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
) : (
{footerLayout.showTabLabels ? (
<Text span size="xs">
{t(`tab.${value}`)}
</Text>
) : (
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
)}
</Center>
),
@@ -152,26 +174,27 @@ export default function MediaTranscodingWidget({
}
}}
size="xs"
style={{ minWidth: 0, flexShrink: footerLayout.showTabLabels ? 1 : 0 }}
/>
<Group gap="xs" ml="auto">
{view === "queue" && (
<Group gap="xs" ml="auto" wrap="nowrap">
{view === "queue" && queuePageCount > 1 && (
<>
<Pagination.Root total={queuePageCount} value={queuePage} onChange={handleQueuePageChange} size="xs">
<Group gap={2} justify="center">
{!isTiny && <Pagination.First disabled={queuePage === 1} />}
<Pagination.Previous disabled={queuePage === 1} />
<Pagination.Next disabled={queuePage === queuePageCount} />
{!isTiny && <Pagination.Last disabled={queuePage === queuePageCount} />}
</Group>
</Pagination.Root>
<Text size="xs">
{t("currentIndex", {
start: String(transcodingData.data.queue.startIndex + 1),
end: String(transcodingData.data.queue.endIndex + 1),
total: String(transcodingData.data.queue.totalCount),
})}
</Text>
<QueuePaginationControls
total={queuePageCount}
value={queuePage}
onChange={handleQueuePageChange}
layout={footerLayout}
/>
{footerLayout.showPageRange && (
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("currentIndex", {
start: String(transcodingData.data.queue.startIndex + 1),
end: String(transcodingData.data.queue.endIndex + 1),
total: String(transcodingData.data.queue.totalCount),
})}
</Text>
)}
</>
)}
@@ -187,6 +210,32 @@ export const getQueuePageSize = (height: number, isAdvanced: boolean): number =>
return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
};
export const getTranscodingFooterLayout = (width: number, height: number) => ({
showTabLabels: width >= 420 && height >= 160,
showPageEdges: width >= 360,
showPageItems: width >= 600,
showPageRange: width >= 760,
});
interface QueuePaginationControlsProps {
total: number;
value: number;
onChange: (page: number) => void;
layout: ReturnType<typeof getTranscodingFooterLayout>;
}
const QueuePaginationControls = ({ total, value, onChange, layout }: QueuePaginationControlsProps) => (
<Pagination.Root total={total} value={value} onChange={onChange} size="xs">
<Group gap={2} justify="center" wrap="nowrap">
{layout.showPageEdges && <Pagination.First disabled={value === 1} />}
<Pagination.Previous disabled={value === 1} />
{layout.showPageItems && <Pagination.Items />}
<Pagination.Next disabled={value === total} />
{layout.showPageEdges && <Pagination.Last disabled={value === total} />}
</Group>
</Pagination.Root>
);
interface QueuePaginationState {
page: number;
pageSize: number;
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getQueuePageSize, resolveQueuePagination } from "./component";
import { getQueuePageSize, getTranscodingFooterLayout, resolveQueuePagination } from "./component";
import { toRingProgressSections } from "./panels/statistics.panel";
describe("media transcoding layout", () => {
@@ -14,6 +14,23 @@ describe("media transcoding layout", () => {
expect(getQueuePageSize(200, true)).toBe(25);
});
test("reveals footer detail only as space becomes available", () => {
expect(getTranscodingFooterLayout(280, 220)).toEqual({
showTabLabels: false,
showPageEdges: false,
showPageItems: false,
showPageRange: false,
});
expect(getTranscodingFooterLayout(600, 220)).toEqual({
showTabLabels: true,
showPageEdges: true,
showPageItems: true,
showPageRange: false,
});
expect(getTranscodingFooterLayout(760, 220).showPageRange).toBe(true);
expect(getTranscodingFooterLayout(760, 120).showTabLabels).toBe(false);
});
test("resets the queue when display mode or page size changes", () => {
const compact = { page: 7, pageSize: 10, isAdvanced: false };