fix(media-transcoding): improve responsive styles (#2550)

* fix(media-transcoding): improve responsive styles

* fix: typecheck issue
This commit is contained in:
Meier Lukas
2025-03-09 14:22:12 +01:00
committed by GitHub
parent d584ade8f4
commit 8f8d7884a9
7 changed files with 224 additions and 229 deletions

View File

@@ -1890,10 +1890,10 @@
"statistics": { "statistics": {
"empty": "Empty", "empty": "Empty",
"transcodes": "Transcodes", "transcodes": "Transcodes",
"transcodesCount": "Transcodes: {value}", "transcodesCount": "Transcodes",
"healthChecksCount": "Health checks: {value}", "healthChecksCount": "Health checks",
"filesCount": "Files: {value}", "filesCount": "Files",
"savedSpace": "Saved space: {value}", "savedSpace": "Saved space",
"healthChecks": "Health checks", "healthChecks": "Health checks",
"videoCodecs": "Codecs", "videoCodecs": "Codecs",
"videoContainers": "Containers", "videoContainers": "Containers",

View File

@@ -2,20 +2,32 @@
import { useState } from "react"; import { useState } from "react";
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core"; import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@tabler/icons-react";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react"; import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { views } from ".";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status"; import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel"; import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel"; import { StatisticsPanel } from "./panels/statistics.panel";
import { WorkersPanel } from "./panels/workers.panel"; import { WorkersPanel } from "./panels/workers.panel";
type Views = "workers" | "queue" | "statistics"; type View = (typeof views)[number];
export default function MediaTranscodingWidget({ integrationIds, options }: WidgetComponentProps<"mediaTranscoding">) { const viewIcons = {
workers: IconCpu2,
queue: IconClipboardList,
statistics: IconReportAnalytics,
} satisfies Record<View, TablerIcon>;
export default function MediaTranscodingWidget({
integrationIds,
options,
width,
}: WidgetComponentProps<"mediaTranscoding">) {
const [queuePage, setQueuePage] = useState(1); const [queuePage, setQueuePage] = useState(1);
const queuePageSize = 10; const queuePageSize = 10;
const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery( const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(
@@ -31,15 +43,16 @@ export default function MediaTranscodingWidget({ integrationIds, options }: Widg
}, },
); );
const [view, setView] = useState<Views>(options.defaultView); const [view, setView] = useState<View>(options.defaultView);
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize); const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const t = useI18n("widget.mediaTranscoding"); const t = useI18n("widget.mediaTranscoding");
const isTiny = width < 256;
return ( return (
<Stack gap={4} h="100%"> <Stack gap={4} h="100%">
{view === "workers" ? ( {view === "workers" ? (
<WorkersPanel workers={transcodingData.data.workers} /> <WorkersPanel workers={transcodingData.data.workers} isTiny={isTiny} />
) : view === "queue" ? ( ) : view === "queue" ? (
<QueuePanel queue={transcodingData.data.queue} /> <QueuePanel queue={transcodingData.data.queue} />
) : ( ) : (
@@ -48,65 +61,48 @@ export default function MediaTranscodingWidget({ integrationIds, options }: Widg
<Divider /> <Divider />
<Group gap="xs" mb={4} ms={4} me={8}> <Group gap="xs" mb={4} ms={4} me={8}>
<SegmentedControl <SegmentedControl
data={[ data={views.map((value) => {
{ const Icon = viewIcons[value];
return {
label: ( label: (
<Center> <Center style={{ gap: 4 }}>
<IconCpu2 size={18} /> <Icon size={12} />
<Text size="xs" ml={8}> {!isTiny && (
{t("tab.workers")} <Text span size="xs">
</Text> {t(`tab.${value}`)}
</Text>
)}
</Center> </Center>
), ),
value: "workers", value,
}, };
{ })}
label: (
<Center>
<IconClipboardList size={18} />
<Text size="xs" ml={8}>
{t("tab.queue")}
</Text>
</Center>
),
value: "queue",
},
{
label: (
<Center>
<IconReportAnalytics size={18} />
<Text size="xs" ml={8}>
{t("tab.statistics")}
</Text>
</Center>
),
value: "statistics",
},
]}
value={view} value={view}
onChange={(value) => setView(value as Views)} onChange={(value) => setView(value as View)}
size="xs" size="xs"
/> />
{view === "queue" && (
<>
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="sm">
<Group gap={5} justify="center">
<Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
<Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
</Group>
</Pagination.Root>
<Text size="xs">
{t("currentIndex", {
start: transcodingData.data.queue.startIndex + 1,
end: transcodingData.data.queue.endIndex + 1,
total: transcodingData.data.queue.totalCount,
})}
</Text>
</>
)}
<Group gap="xs" ml="auto"> <Group gap="xs" ml="auto">
{view === "queue" && (
<>
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="xs">
<Group gap={2} justify="center">
{!isTiny && <Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />}
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
{!isTiny && <Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />}
</Group>
</Pagination.Root>
<Text size="xs">
{t("currentIndex", {
start: transcodingData.data.queue.startIndex + 1,
end: transcodingData.data.queue.endIndex + 1,
total: transcodingData.data.queue.totalCount,
})}
</Text>
</>
)}
<HealthCheckStatus statistics={transcodingData.data.statistics} /> <HealthCheckStatus statistics={transcodingData.data.statistics} />
</Group> </Group>
</Group> </Group>

View File

@@ -23,8 +23,8 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
return ( return (
<HoverCard position="bottom" width={250} shadow="sm"> <HoverCard position="bottom" width={250} shadow="sm">
<HoverCard.Target> <HoverCard.Target>
<Indicator color={textColor(indicatorColor, colorScheme)} size={8} display="flex"> <Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size={20} /> <IconHeartbeat size={16} />
</Indicator> </Indicator>
</HoverCard.Target> </HoverCard.Target>
<HoverCard.Dropdown bg={colorScheme === "light" ? "gray.2" : "dark.8"}> <HoverCard.Dropdown bg={colorScheme === "light" ? "gray.2" : "dark.8"}>

View File

@@ -1,20 +1,20 @@
import { IconTransform } from "@tabler/icons-react"; import { IconTransform } from "@tabler/icons-react";
import { z } from "zod"; import { z } from "zod";
import { capitalize } from "@homarr/common";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform, icon: IconTransform,
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
defaultView: factory.select({ defaultView: factory.select({
defaultValue: "statistics", defaultValue: "statistics",
options: [ options: views.map((view) => ({ label: capitalize(view), value: view })),
{ label: "Workers", value: "workers" },
{ label: "Queue", value: "queue" },
{ label: "Statistics", value: "statistics" },
],
}), }),
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }), queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
})); }));

View File

@@ -1,4 +1,4 @@
import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core"; import { Center, Group, ScrollArea, Table, TableTd, TableTh, TableTr, Text, Title, Tooltip } from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
@@ -17,7 +17,7 @@ export function QueuePanel(props: QueuePanelProps) {
if (queue.array.length === 0) { if (queue.array.length === 0) {
return ( return (
<Center style={{ flex: "1" }}> <Center style={{ flex: "1" }}>
<Title order={3}>{t("empty")}</Title> <Title order={6}>{t("empty")}</Title>
</Center> </Center>
); );
} }
@@ -26,36 +26,42 @@ export function QueuePanel(props: QueuePanelProps) {
<ScrollArea style={{ flex: "1" }}> <ScrollArea style={{ flex: "1" }}>
<Table style={{ tableLayout: "fixed" }}> <Table style={{ tableLayout: "fixed" }}>
<Table.Thead> <Table.Thead>
<tr> <TableTr>
<th>{t("table.file")}</th> <TableTh ta="start" py={4}>
<th style={{ width: 80 }}>{t("table.size")}</th> <Text size="xs" fw="bold">
</tr> {t("table.file")}
</Text>
</TableTh>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.size")}
</Text>
</TableTh>
</TableTr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{queue.array.map((item) => ( {queue.array.map((item) => (
<tr key={item.id}> <TableTr key={item.id}>
<td> <TableTd py={2}>
<Group gap="xs" wrap="nowrap"> <Group gap={4} wrap="nowrap">
<div> {item.type === "transcode" ? (
{item.type === "transcode" ? ( <Tooltip label={t("table.transcode")}>
<Tooltip label={t("table.transcode")}> <IconTransform size={12} />
<IconTransform size={14} /> </Tooltip>
</Tooltip> ) : (
) : ( <Tooltip label={t("table.healthCheck")}>
<Tooltip label={t("table.healthCheck")}> <IconHeartbeat size={12} />
<IconHeartbeat size={14} /> </Tooltip>
</Tooltip> )}
)}
</div>
<Text lineClamp={1} size="xs"> <Text lineClamp={1} size="xs">
{item.filePath.split("\\").pop()?.split("/").pop() ?? item.filePath} {item.filePath.split("\\").pop()?.split("/").pop() ?? item.filePath}
</Text> </Text>
</Group> </Group>
</td> </TableTd>
<td> <TableTd py={2}>
<Text size="xs">{humanFileSize(item.fileSize)}</Text> <Text size="xs">{humanFileSize(item.fileSize)}</Text>
</td> </TableTd>
</tr> </TableTr>
))} ))}
</Table.Tbody> </Table.Tbody>
</Table> </Table>

View File

@@ -1,11 +1,12 @@
import type react from "react";
import type { MantineColor, RingProgressProps } from "@mantine/core"; import type { MantineColor, RingProgressProps } from "@mantine/core";
import { Box, Center, Grid, Group, RingProgress, Stack, Text, Title, useMantineColorScheme } from "@mantine/core"; import { Card, Center, Group, RingProgress, ScrollArea, Stack, Text, Title, Tooltip } from "@mantine/core";
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations"; import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"]; const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"];
@@ -21,90 +22,54 @@ export function StatisticsPanel(props: StatisticsPanelProps) {
if (!allLibs) { if (!allLibs) {
return ( return (
<Center style={{ flex: "1" }}> <Center style={{ flex: "1" }}>
<Title order={3}>{t("empty")}</Title> <Title order={6}>{t("empty")}</Title>
</Center> </Center>
); );
} }
return ( return (
<Stack style={{ flex: "1" }} gap="xs"> <ScrollArea h="100%">
<Group <Group wrap="wrap" justify="center" p={4} w="100%" gap="xs">
style={{ <StatisticItem icon={IconTransform} label={t("transcodesCount")} value={props.statistics.totalTranscodeCount} />
flex: 1, <StatisticItem
}} icon={IconHeartbeat}
justify="apart" label={t("healthChecksCount")}
align="center" value={props.statistics.totalHealthCheckCount}
wrap="nowrap" />
> <StatisticItem icon={IconFileDescription} label={t("filesCount")} value={props.statistics.totalFileCount} />
<Stack align="center" gap={0}> <StatisticItem
<RingProgress size={120} sections={toRingProgressSections(allLibs.transcodeStatus)} /> icon={IconDatabaseHeart}
<Text size="xs">{t("transcodes")}</Text> label={t("savedSpace")}
</Stack> value={humanFileSize(Math.floor(allLibs.savedSpace))}
<Grid gutter="xs"> />
<Grid.Col span={6}>
<StatBox
icon={<IconTransform size={18} />}
label={t("transcodesCount", {
value: props.statistics.totalTranscodeCount,
})}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatBox
icon={<IconHeartbeat size={18} />}
label={t("healthChecksCount", {
value: props.statistics.totalHealthCheckCount,
})}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatBox
icon={<IconFileDescription size={18} />}
label={t("filesCount", {
value: props.statistics.totalFileCount,
})}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatBox
icon={<IconDatabaseHeart size={18} />}
label={t("savedSpace", {
value: humanFileSize(Math.floor(allLibs.savedSpace)),
})}
/>
</Grid.Col>
</Grid>
<Stack align="center" gap={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.healthCheckStatus)} />
<Text size="xs">{t("healthChecks")}</Text>
</Stack>
</Group> </Group>
<Group <Group justify="center" wrap="wrap" grow>
style={{ <StatisticRingProgress items={allLibs.transcodeStatus} label={t("transcodes")} />
flex: 1, <StatisticRingProgress items={allLibs.healthCheckStatus} label={t("healthChecks")} />
}} <StatisticRingProgress items={allLibs.videoCodecs} label={t("videoCodecs")} />
justify="space-between" <StatisticRingProgress items={allLibs.videoContainers} label={t("videoContainers")} />
align="center" <StatisticRingProgress items={allLibs.videoResolutions} label={t("videoResolutions")} />
wrap="nowrap"
w="100%"
>
<Stack align="center" gap={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoCodecs)} />
<Text size="xs">{t("videoCodecs")}</Text>
</Stack>
<Stack align="center" gap={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoContainers)} />
<Text size="xs">{t("videoContainers")}</Text>
</Stack>
<Stack align="center" gap={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoResolutions)} />
<Text size="xs">{t("videoResolutions")}</Text>
</Stack>
</Group> </Group>
</Stack> </ScrollArea>
); );
} }
interface StatisticRingProgressProps {
items: TdarrPieSegment[];
label: string;
}
const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) => {
return (
<Stack align="center" gap={0} miw={60}>
<Text size="10px" ta="center" style={{ whiteSpace: "nowrap" }}>
{label}
</Text>
<RingProgress size={60} thickness={6} sections={toRingProgressSections(items)} />
</Stack>
);
};
function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] { function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
const total = segments.reduce((prev, curr) => prev + curr.value, 0); const total = segments.reduce((prev, curr) => prev + curr.value, 0);
return segments.map((segment, index) => ({ return segments.map((segment, index) => ({
@@ -115,26 +80,22 @@ function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps[
})); }));
} }
interface StatBoxProps { interface StatisticItemProps {
icon: react.ReactNode; icon: TablerIcon;
value: string | number;
label: string; label: string;
} }
function StatBox(props: StatBoxProps) { function StatisticItem(props: StatisticItemProps) {
const { colorScheme } = useMantineColorScheme(); const board = useRequiredBoard();
return ( return (
<Box <Tooltip label={props.label}>
style={(theme) => ({ <Card p={0} withBorder radius={board.itemRadius} miw={48} flex={1}>
padding: theme.spacing.xs, <Group justify="center" align="center" gap="xs" w="100%" wrap="nowrap">
border: "1px solid", <props.icon size={16} style={{ minWidth: 16 }} />
borderRadius: theme.radius.md, <Text size="md">{props.value}</Text>
borderColor: colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1], </Group>
})} </Card>
> </Tooltip>
<Stack gap="xs" align="center">
{props.icon}
<Text size="xs">{props.label}</Text>
</Stack>
</Box>
); );
} }

View File

@@ -1,4 +1,16 @@
import { Center, Group, Progress, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core"; import {
Center,
Group,
Progress,
ScrollArea,
Table,
TableTd,
TableTh,
TableTr,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
import type { TdarrWorker } from "@homarr/integrations"; import type { TdarrWorker } from "@homarr/integrations";
@@ -6,6 +18,7 @@ import { useI18n } from "@homarr/translation/client";
interface WorkersPanelProps { interface WorkersPanelProps {
workers: TdarrWorker[]; workers: TdarrWorker[];
isTiny: boolean;
} }
export function WorkersPanel(props: WorkersPanelProps) { export function WorkersPanel(props: WorkersPanelProps) {
@@ -14,7 +27,7 @@ export function WorkersPanel(props: WorkersPanelProps) {
if (props.workers.length === 0) { if (props.workers.length === 0) {
return ( return (
<Center style={{ flex: "1" }}> <Center style={{ flex: "1" }}>
<Title order={3}>{t("empty")}</Title> <Title order={6}>{t("empty")}</Title>
</Center> </Center>
); );
} }
@@ -23,52 +36,71 @@ export function WorkersPanel(props: WorkersPanelProps) {
<ScrollArea style={{ flex: "1" }}> <ScrollArea style={{ flex: "1" }}>
<Table style={{ tableLayout: "fixed" }}> <Table style={{ tableLayout: "fixed" }}>
<Table.Thead> <Table.Thead>
<tr> <TableTr>
<th>{t("table.file")}</th> <TableTh ta="start" py={4}>
<th style={{ width: 60 }}>{t("table.eta")}</th> <Text size="xs" fw="bold">
<th style={{ width: 175 }}>{t("table.progress")}</th> {t("table.file")}
</tr> </Text>
</TableTh>
<TableTh ta="start" py={4} w={50}>
<Text size="xs" fw="bold">
{t("table.eta")}
</Text>
</TableTh>
<TableTh ta="start" py={4}>
<Text size="xs" fw="bold">
{t("table.progress")}
</Text>
</TableTh>
</TableTr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{props.workers.map((worker) => ( {props.workers.map((worker) => {
<tr key={worker.id}> const fileName = worker.filePath.split("\\").pop()?.split("/").pop() ?? worker.filePath;
<td> return (
<Group gap="xs" wrap="nowrap"> <TableTr key={worker.id}>
<div> <TableTd py={2}>
{worker.jobType === "transcode" ? ( <Group gap="xs" wrap="nowrap">
<Tooltip label={t("table.transcode")}> <div>
<IconTransform size={14} /> {worker.jobType === "transcode" ? (
</Tooltip> <Tooltip label={t("table.transcode")}>
) : ( <IconTransform size={14} />
<Tooltip label={t("table.healthCheck")}> </Tooltip>
<IconHeartbeat size={14} /> ) : (
</Tooltip> <Tooltip label={t("table.healthCheck")}>
<IconHeartbeat size={14} />
</Tooltip>
)}
</div>
<Text lineClamp={1} size="xs" title={fileName}>
{fileName}
</Text>
</Group>
</TableTd>
<TableTd py={2}>
<Text size="xs">{worker.ETA.startsWith("0:") ? worker.ETA.substring(2) : worker.ETA}</Text>
</TableTd>
<TableTd py={2}>
<Group wrap="nowrap" gap="xs">
{!props.isTiny && (
<>
<Text size="xs">{worker.step}</Text>
<Progress
value={worker.percentage}
size="lg"
radius="xl"
style={{
flex: 1,
}}
/>
</>
)} )}
</div> <Text size="xs">{Math.round(worker.percentage)}%</Text>
<Text lineClamp={1} size="xs"> </Group>
{worker.filePath.split("\\").pop()?.split("/").pop() ?? worker.filePath} </TableTd>
</Text> </TableTr>
</Group> );
</td> })}
<td>
<Text size="xs">{worker.ETA.startsWith("0:") ? worker.ETA.substring(2) : worker.ETA}</Text>
</td>
<td>
<Group wrap="nowrap" gap="xs">
<Text size="xs">{worker.step}</Text>
<Progress
value={worker.percentage}
size="lg"
radius="xl"
style={{
flex: 1,
}}
/>
<Text size="xs">{Math.round(worker.percentage)}%</Text>
</Group>
</td>
</tr>
))}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</ScrollArea> </ScrollArea>