feat: remove cqmin system (#2407)
* feat: remove cqmin system * fix: improve weather widget --------- Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -74,12 +74,11 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
|
|||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
direction="column"
|
direction="column"
|
||||||
p="7.5cqmin"
|
|
||||||
justify="center"
|
justify="center"
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
{options.showTitle && (
|
{options.showTitle && (
|
||||||
<Text className="app-title" fw={700} ta="center" size="12.5cqmin">
|
<Text className="app-title" fw={700} ta="center">
|
||||||
{app.name}
|
{app.name}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => {
|
|||||||
const { pingIconsEnabled } = useSettings();
|
const { pingIconsEnabled } = useSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">
|
<Box bottom={10} right={10} pos="absolute" display={"flex"}>
|
||||||
<Tooltip label={tooltip}>
|
<Tooltip label={tooltip}>
|
||||||
{pingIconsEnabled ? (
|
{pingIconsEnabled ? (
|
||||||
<props.icon style={{ width: "10cqmin", height: "10cqmin" }} color={color} />
|
<props.icon style={{ width: 20, height: 20 }} strokeWidth={5} color={color} />
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box
|
||||||
bg={color}
|
bg={color}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: "100%",
|
borderRadius: "100%",
|
||||||
}}
|
}}
|
||||||
w="10cqmin"
|
w={16}
|
||||||
h="10cqmin"
|
h={16}
|
||||||
></Box>
|
></Box>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="light"] .card-grid {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .card-grid {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
|
||||||
.card:hover > div > div.bookmarkIcon {
|
.card:hover > div > div.bookmarkIcon {
|
||||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
background-color: var(--mantine-color-iconColor-filled-hover);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Anchor, Box, Card, Divider, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
import { Anchor, Box, Card, Divider, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -42,9 +43,11 @@ export default function BookmarksWidget({ options, width, height, itemId }: Widg
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="sm" p="sm">
|
<Stack h="100%" gap="sm" p="sm">
|
||||||
<Title order={4} px="0.25rem">
|
{options.title.length > 0 && (
|
||||||
{options.title}
|
<Title order={4} px="0.25rem">
|
||||||
</Title>
|
{options.title}
|
||||||
|
</Title>
|
||||||
|
)}
|
||||||
{options.layout === "grid" && (
|
{options.layout === "grid" && (
|
||||||
<GridLayout
|
<GridLayout
|
||||||
data={data}
|
data={data}
|
||||||
@@ -80,8 +83,9 @@ interface FlexLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FlexLayout = ({ data, direction, hideIcon, hideHostname, openNewTab, hasIconColor }: FlexLayoutProps) => {
|
const FlexLayout = ({ data, direction, hideIcon, hideHostname, openNewTab, hasIconColor }: FlexLayoutProps) => {
|
||||||
|
const board = useRequiredBoard();
|
||||||
return (
|
return (
|
||||||
<Flex direction={direction} gap="0" h="100%" w="100%">
|
<Flex direction={direction} gap="0" w="100%">
|
||||||
{data.map((app, index) => (
|
{data.map((app, index) => (
|
||||||
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
|
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
|
||||||
<Divider
|
<Divider
|
||||||
@@ -95,18 +99,9 @@ const FlexLayout = ({ data, direction, hideIcon, hideHostname, openNewTab, hasIc
|
|||||||
target={openNewTab ? "_blank" : "_self"}
|
target={openNewTab ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
key={app.id}
|
key={app.id}
|
||||||
h="100%"
|
|
||||||
w="100%"
|
w="100%"
|
||||||
>
|
>
|
||||||
<Card
|
<Card radius={board.itemRadius} className={classes.card} w="100%" display="flex" p={"xs"} h={"100%"}>
|
||||||
radius="md"
|
|
||||||
style={{ containerType: "size" }}
|
|
||||||
className={classes.card}
|
|
||||||
h="100%"
|
|
||||||
w="100%"
|
|
||||||
display="flex"
|
|
||||||
p={0}
|
|
||||||
>
|
|
||||||
{direction === "row" ? (
|
{direction === "row" ? (
|
||||||
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} hasIconColor={hasIconColor} />
|
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} hasIconColor={hasIconColor} />
|
||||||
) : (
|
) : (
|
||||||
@@ -134,12 +129,14 @@ const GridLayout = ({ data, width, height, hideIcon, hideHostname, openNewTab, h
|
|||||||
// Calculates the perfect number of columns for the grid layout based on the width and height in pixels and the number of items
|
// Calculates the perfect number of columns for the grid layout based on the width and height in pixels and the number of items
|
||||||
const columns = Math.ceil(Math.sqrt(data.length * (width / height)));
|
const columns = Math.ceil(Math.sqrt(data.length * (width / height)));
|
||||||
|
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
display="grid"
|
display="grid"
|
||||||
h="100%"
|
h="100%"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${columns}, auto)`,
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -152,8 +149,19 @@ const GridLayout = ({ data, width, height, hideIcon, hideHostname, openNewTab, h
|
|||||||
key={app.id}
|
key={app.id}
|
||||||
h="100%"
|
h="100%"
|
||||||
>
|
>
|
||||||
<Card withBorder style={{ containerType: "size" }} h="100%" className={classes.card} p="5cqmin">
|
<Card
|
||||||
<VerticalItem app={app} hideIcon={hideIcon} hideHostname={hideHostname} hasIconColor={hasIconColor} />
|
h="100%"
|
||||||
|
className={combineClasses(classes.card, classes["card-grid"])}
|
||||||
|
radius={board.itemRadius}
|
||||||
|
p="sm"
|
||||||
|
>
|
||||||
|
<VerticalItem
|
||||||
|
app={app}
|
||||||
|
hideIcon={hideIcon}
|
||||||
|
hideHostname={hideHostname}
|
||||||
|
hasIconColor={hasIconColor}
|
||||||
|
size={50}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
))}
|
))}
|
||||||
@@ -166,15 +174,17 @@ const VerticalItem = ({
|
|||||||
hideIcon,
|
hideIcon,
|
||||||
hideHostname,
|
hideHostname,
|
||||||
hasIconColor,
|
hasIconColor,
|
||||||
|
size = 30,
|
||||||
}: {
|
}: {
|
||||||
app: RouterOutputs["app"]["byIds"][number];
|
app: RouterOutputs["app"]["byIds"][number];
|
||||||
hideIcon: boolean;
|
hideIcon: boolean;
|
||||||
hideHostname: boolean;
|
hideHostname: boolean;
|
||||||
hasIconColor: boolean;
|
hasIconColor: boolean;
|
||||||
|
size?: number;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="5cqmin">
|
<Stack h="100%" gap="sm">
|
||||||
<Text fw={700} ta="center" size="20cqmin">
|
<Text fw={700} ta="center" size="lg">
|
||||||
{app.name}
|
{app.name}
|
||||||
</Text>
|
</Text>
|
||||||
{!hideIcon && (
|
{!hideIcon && (
|
||||||
@@ -184,16 +194,18 @@ const VerticalItem = ({
|
|||||||
alt={app.name}
|
alt={app.name}
|
||||||
className={classes.bookmarkIcon}
|
className={classes.bookmarkIcon}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: "100%",
|
width: size,
|
||||||
maxWidth: "100%",
|
height: size,
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
|
marginLeft: "auto",
|
||||||
|
marginRight: "auto",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hideHostname && (
|
{!hideHostname && (
|
||||||
<Anchor ta="center" component="span" size="12cqmin">
|
<Anchor ta="center" component="span" size="lg">
|
||||||
{app.href ? new URL(app.href).hostname : undefined}
|
{app.href ? new URL(app.href).hostname : undefined}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
@@ -213,7 +225,7 @@ const HorizontalItem = ({
|
|||||||
hasIconColor: boolean;
|
hasIconColor: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap" gap={"xs"}>
|
||||||
{!hideIcon && (
|
{!hideIcon && (
|
||||||
<MaskedOrNormalImage
|
<MaskedOrNormalImage
|
||||||
imageUrl={app.iconUrl}
|
imageUrl={app.iconUrl}
|
||||||
@@ -223,20 +235,19 @@ const HorizontalItem = ({
|
|||||||
style={{
|
style={{
|
||||||
overflow: "auto",
|
overflow: "auto",
|
||||||
scale: 0.8,
|
scale: 0.8,
|
||||||
minHeight: "100cqh",
|
width: 30,
|
||||||
maxHeight: "100cqh",
|
height: 30,
|
||||||
minWidth: "100cqh",
|
flex: "unset",
|
||||||
maxWidth: "100cqh",
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Stack justify="space-between" gap={0}>
|
<Stack justify="space-between" gap={0}>
|
||||||
<Text fw={700} size="45cqh" lineClamp={1}>
|
<Text fw={700} size="md" lineClamp={1}>
|
||||||
{app.name}
|
{app.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!hideHostname && (
|
{!hideHostname && (
|
||||||
<Anchor component="span" size="30cqh">
|
<Anchor component="span" size="xs">
|
||||||
{app.href ? new URL(app.href).hostname : undefined}
|
{app.href ? new URL(app.href).hostname : undefined}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Container, Popover, useMantineTheme } from "@mantine/core";
|
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||||
|
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import { CalendarEventList } from "./calendar-event-list";
|
import { CalendarEventList } from "./calendar-event-list";
|
||||||
@@ -9,11 +10,19 @@ interface CalendarDayProps {
|
|||||||
date: Date;
|
date: Date;
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
rootWidth: number;
|
||||||
|
rootHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CalendarDay = ({ date, events, disabled }: CalendarDayProps) => {
|
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
|
||||||
const [opened, setOpend] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const { primaryColor } = useMantineTheme();
|
const { primaryColor } = useMantineTheme();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const mantineTheme = useMantineTheme();
|
||||||
|
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||||
|
|
||||||
|
const minAxisSize = Math.min(rootWidth, rootHeight);
|
||||||
|
const shouldScaleDown = minAxisSize < 350;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -25,7 +34,7 @@ export const CalendarDay = ({ date, events, disabled }: CalendarDayProps) => {
|
|||||||
transitionProps={{
|
transitionProps={{
|
||||||
transition: "pop",
|
transition: "pop",
|
||||||
}}
|
}}
|
||||||
onChange={setOpend}
|
onChange={setOpened}
|
||||||
opened={opened}
|
opened={opened}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
@@ -35,30 +44,23 @@ export const CalendarDay = ({ date, events, disabled }: CalendarDayProps) => {
|
|||||||
w="100%"
|
w="100%"
|
||||||
p={0}
|
p={0}
|
||||||
m={0}
|
m={0}
|
||||||
bd={`1cqmin solid ${opened && !disabled ? primaryColor : "transparent"}`}
|
bd={`3px solid ${opened && !disabled ? primaryColor : "transparent"}`}
|
||||||
|
pos={"relative"}
|
||||||
style={{
|
style={{
|
||||||
alignContent: "center",
|
alignContent: "center",
|
||||||
borderRadius: "3.5cqmin",
|
borderRadius: actualItemRadius,
|
||||||
cursor: disabled ? "default" : "pointer",
|
cursor: disabled ? "default" : "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
|
||||||
setOpend((prev) => !prev);
|
setOpened((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
fontSize: "5cqmin",
|
|
||||||
lineHeight: "5cqmin",
|
|
||||||
paddingTop: "1.25cqmin",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</div>
|
</Text>
|
||||||
<NotificationIndicator events={events} />
|
{rootHeight >= 350 && <NotificationIndicator events={events} />}
|
||||||
</Container>
|
</Container>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
@@ -75,19 +77,10 @@ interface NotificationIndicatorProps {
|
|||||||
const NotificationIndicator = ({ events }: NotificationIndicatorProps) => {
|
const NotificationIndicator = ({ events }: NotificationIndicatorProps) => {
|
||||||
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
|
const notificationEvents = [...new Set(events.map((event) => event.links[0]?.notificationColor))].filter(String);
|
||||||
return (
|
return (
|
||||||
<Container h="0.7cqmin" w="80%" display="flex" p={0} style={{ flexDirection: "row", justifyContent: "center" }}>
|
<Flex h="xs" w="75%" pos={"absolute"} bottom={0} left={"12.5%"} p={0} direction={"row"} justify={"center"}>
|
||||||
{notificationEvents.map((notificationEvent) => {
|
{notificationEvents.map((notificationEvent) => {
|
||||||
return (
|
return <Box key={notificationEvent} bg={notificationEvent} h={4} p={0} style={{ flex: 1, borderRadius: 5 }} />;
|
||||||
<Container
|
|
||||||
key={notificationEvent}
|
|
||||||
bg={notificationEvent}
|
|
||||||
h="100%"
|
|
||||||
mx="0.25cqmin"
|
|
||||||
p={0}
|
|
||||||
style={{ flex: 1, borderRadius: "1000px" }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</Container>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import { useMantineTheme } from "@mantine/core";
|
||||||
import { Calendar } from "@mantine/dates";
|
import { Calendar } from "@mantine/dates";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
@@ -60,6 +63,10 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const locale = params.locale as string;
|
const locale = params.locale as string;
|
||||||
const { firstDayOfWeek } = useSettings();
|
const { firstDayOfWeek } = useSettings();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const mantineTheme = useMantineTheme();
|
||||||
|
const actualItemRadius = mantineTheme.radius[board.itemRadius];
|
||||||
|
const { ref, width, height } = useElementSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Calendar
|
<Calendar
|
||||||
@@ -72,43 +79,39 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
|||||||
date={month}
|
date={month}
|
||||||
maxLevel="month"
|
maxLevel="month"
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
w="100%"
|
|
||||||
h="100%"
|
|
||||||
static={isEditMode}
|
static={isEditMode}
|
||||||
className={classes.calendar}
|
className={classes.calendar}
|
||||||
|
w={"100%"}
|
||||||
|
h={"100%"}
|
||||||
|
ref={ref}
|
||||||
styles={{
|
styles={{
|
||||||
calendarHeaderControl: {
|
calendarHeaderControl: {
|
||||||
pointerEvents: isEditMode ? "none" : undefined,
|
pointerEvents: isEditMode ? "none" : undefined,
|
||||||
height: "12cqmin",
|
borderRadius: "md",
|
||||||
width: "12cqmin",
|
|
||||||
borderRadius: "3.5cqmin",
|
|
||||||
},
|
},
|
||||||
calendarHeaderLevel: {
|
calendarHeaderLevel: {
|
||||||
height: "12cqmin",
|
|
||||||
fontSize: "6cqmin",
|
|
||||||
pointerEvents: "none",
|
pointerEvents: "none",
|
||||||
},
|
},
|
||||||
levelsGroup: {
|
levelsGroup: {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
padding: "2.5cqmin",
|
padding: "md",
|
||||||
},
|
},
|
||||||
calendarHeader: {
|
calendarHeader: {
|
||||||
maxWidth: "unset",
|
maxWidth: "unset",
|
||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
},
|
},
|
||||||
day: {
|
|
||||||
width: "12cqmin",
|
|
||||||
height: "12cqmin",
|
|
||||||
borderRadius: "3.5cqmin",
|
|
||||||
},
|
|
||||||
monthCell: {
|
monthCell: {
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
|
day: {
|
||||||
|
borderRadius: actualItemRadius,
|
||||||
|
width: width < 350 ? 20 : 50,
|
||||||
|
height: height < 350 ? 20 : 50,
|
||||||
|
},
|
||||||
month: {
|
month: {
|
||||||
height: "100%",
|
height: "100%",
|
||||||
},
|
},
|
||||||
weekday: {
|
weekday: {
|
||||||
fontSize: "5.5cqmin",
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -122,7 +125,13 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
|||||||
}))
|
}))
|
||||||
.filter((event): event is CalendarEvent => Boolean(event.date));
|
.filter((event): event is CalendarEvent => Boolean(event.date));
|
||||||
return (
|
return (
|
||||||
<CalendarDay date={tileDate} events={eventsForDate} disabled={isEditMode || eventsForDate.length === 0} />
|
<CalendarDay
|
||||||
|
date={tileDate}
|
||||||
|
events={eventsForDate}
|
||||||
|
disabled={isEditMode || eventsForDate.length === 0}
|
||||||
|
rootWidth={width}
|
||||||
|
rootHeight={height}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Stack, Text } from "@mantine/core";
|
import { Stack, Text, Title } from "@mantine/core";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||||
import timezones from "dayjs/plugin/timezone";
|
import timezones from "dayjs/plugin/timezone";
|
||||||
@@ -22,19 +22,19 @@ export default function ClockWidget({ options }: WidgetComponentProps<"clock">)
|
|||||||
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
const time = useCurrentTime(options);
|
const time = useCurrentTime(options);
|
||||||
return (
|
return (
|
||||||
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap="10cqmin">
|
<Stack className="clock-text-stack" h="100%" align="center" justify="center" gap="sm">
|
||||||
{options.customTitleToggle && (
|
{options.customTitleToggle && (
|
||||||
<Text className="clock-customTitle-text" size="12.5cqmin" ta="center">
|
<Text className="clock-customTitle-text" size="md" ta="center">
|
||||||
{options.customTitle}
|
{options.customTitle}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text className="clock-time-text" fw={700} size="22.5cqmin" lh="1">
|
<Title className="clock-time-text" fw={700} order={1} lh="1">
|
||||||
{options.customTimeFormat
|
{options.customTimeFormat
|
||||||
? dayjs(time).tz(timezone).format(customTimeFormat)
|
? dayjs(time).tz(timezone).format(customTimeFormat)
|
||||||
: dayjs(time).tz(timezone).format(timeFormat)}
|
: dayjs(time).tz(timezone).format(timeFormat)}
|
||||||
</Text>
|
</Title>
|
||||||
{options.showDate && (
|
{options.showDate && (
|
||||||
<Text className="clock-date-text" size="12.5cqmin" p="1cqmin" lineClamp={1}>
|
<Text className="clock-date-text" size="md" p="sm" lineClamp={1}>
|
||||||
{options.customDateFormat
|
{options.customDateFormat
|
||||||
? dayjs(time).tz(timezone).format(customDateFormat)
|
? dayjs(time).tz(timezone).format(customDateFormat)
|
||||||
: dayjs(time).tz(timezone).format(dateFormat)}
|
: dayjs(time).tz(timezone).format(dateFormat)}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[data-mantine-color-scheme="light"] .card {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .card {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useState } from "react";
|
|||||||
import { ActionIcon, Badge, Button, Card, Flex, ScrollArea, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
import { ActionIcon, Badge, Button, Card, Flex, ScrollArea, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
|
import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -19,6 +20,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
|
|||||||
|
|
||||||
import type { widgetKind } from ".";
|
import type { widgetKind } from ".";
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
|
import classes from "./component.module.css";
|
||||||
import TimerModal from "./TimerModal";
|
import TimerModal from "./TimerModal";
|
||||||
|
|
||||||
const dnsLightStatus = (enabled: boolean | undefined) =>
|
const dnsLightStatus = (enabled: boolean | undefined) =>
|
||||||
@@ -179,12 +181,12 @@ export default function DnsHoleControlsWidget({
|
|||||||
className="dns-hole-controls-stack"
|
className="dns-hole-controls-stack"
|
||||||
h="100%"
|
h="100%"
|
||||||
direction="column"
|
direction="column"
|
||||||
p="2.5cqmin"
|
p="sm"
|
||||||
gap="2.5cqmin"
|
gap="sm"
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
{controlAllButtonsVisible && (
|
{controlAllButtonsVisible && (
|
||||||
<Flex className="dns-hole-controls-buttons" gap="2.5cqmin">
|
<Flex className="dns-hole-controls-buttons" gap="sm">
|
||||||
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
|
||||||
<Button
|
<Button
|
||||||
className="dns-hole-controls-enable-all-button"
|
className="dns-hole-controls-enable-all-button"
|
||||||
@@ -193,15 +195,12 @@ export default function DnsHoleControlsWidget({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
h="fit-content"
|
h="fit-content"
|
||||||
p="1.25cqmin"
|
p="xs"
|
||||||
bd={0}
|
bd={0}
|
||||||
radius="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
flex={1}
|
flex={1}
|
||||||
>
|
>
|
||||||
<IconPlayerPlay
|
<IconPlayerPlay className="dns-hole-controls-enable-all-icon" size={24} />
|
||||||
className="dns-hole-controls-enable-all-icon"
|
|
||||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -216,15 +215,12 @@ export default function DnsHoleControlsWidget({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="yellow"
|
color="yellow"
|
||||||
h="fit-content"
|
h="fit-content"
|
||||||
p="1.25cqmin"
|
p="xs"
|
||||||
bd={0}
|
bd={0}
|
||||||
radius="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
flex={1}
|
flex={1}
|
||||||
>
|
>
|
||||||
<IconClockPause
|
<IconClockPause className="dns-hole-controls-timer-all-icon" size={24} />
|
||||||
className="dns-hole-controls-timer-all-icon"
|
|
||||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -236,15 +232,12 @@ export default function DnsHoleControlsWidget({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
h="fit-content"
|
h="fit-content"
|
||||||
p="1.25cqmin"
|
p="xs"
|
||||||
bd={0}
|
bd={0}
|
||||||
radius="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
flex={1}
|
flex={1}
|
||||||
>
|
>
|
||||||
<IconPlayerStop
|
<IconPlayerStop className="dns-hole-controls-disable-all-icon" size={24} />
|
||||||
className="dns-hole-controls-disable-all-icon"
|
|
||||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -253,7 +246,7 @@ export default function DnsHoleControlsWidget({
|
|||||||
<ScrollArea className="dns-hole-controls-integration-list-scroll-area flexed-scroll-area">
|
<ScrollArea className="dns-hole-controls-integration-list-scroll-area flexed-scroll-area">
|
||||||
<Stack
|
<Stack
|
||||||
className="dns-hole-controls-integration-list"
|
className="dns-hole-controls-integration-list"
|
||||||
gap="2.5cqmin"
|
gap="sm"
|
||||||
flex={1}
|
flex={1}
|
||||||
justify={controlAllButtonsVisible ? "flex-end" : "space-evenly"}
|
justify={controlAllButtonsVisible ? "flex-end" : "space-evenly"}
|
||||||
>
|
>
|
||||||
@@ -306,34 +299,40 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
|
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
|
||||||
// Use all factors to infer the state of the action buttons
|
// Use all factors to infer the state of the action buttons
|
||||||
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`dns-hole-controls-integration-item-outer-shell dns-hole-controls-integration-item-${data.integration.id} dns-hole-controls-integration-item-${data.integration.name}`}
|
className={combineClasses(
|
||||||
|
"dns-hole-controls-integration-item-outer-shell",
|
||||||
|
`dns-hole-controls-integration-item-${data.integration.id}`,
|
||||||
|
`dns-hole-controls-integration-item-${data.integration.name}`,
|
||||||
|
classes.card,
|
||||||
|
)}
|
||||||
key={data.integration.id}
|
key={data.integration.id}
|
||||||
withBorder
|
p="sm"
|
||||||
p="2.5cqmin"
|
py={8}
|
||||||
radius="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
>
|
>
|
||||||
<Flex className="dns-hole-controls-item-container" gap="4cqmin" align="center" direction="row">
|
<Flex className="dns-hole-controls-item-container" gap="md" align="center" direction="row">
|
||||||
<MaskedOrNormalImage
|
<MaskedOrNormalImage
|
||||||
imageUrl={iconUrl}
|
imageUrl={iconUrl}
|
||||||
hasColor={hasIconColor}
|
hasColor={hasIconColor}
|
||||||
alt={data.integration.name}
|
alt={data.integration.name}
|
||||||
className="dns-hole-controls-item-icon"
|
className="dns-hole-controls-item-icon"
|
||||||
style={{
|
style={{
|
||||||
height: "20cqmin",
|
height: 30,
|
||||||
width: "20cqmin",
|
width: 30,
|
||||||
filter: !isConnected ? "grayscale(100%)" : undefined,
|
filter: !isConnected ? "grayscale(100%)" : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Flex className="dns-hole-controls-item-data-stack" direction="column" gap="1.5cqmin">
|
<Flex className="dns-hole-controls-item-data-stack" direction="column" gap={5}>
|
||||||
<Text className="dns-hole-controls-item-integration-name" fz="7cqmin">
|
<Text className="dns-hole-controls-item-integration-name" fz="md" fw={"bold"}>
|
||||||
{data.integration.name}
|
{data.integration.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex className="dns-hole-controls-item-controls" direction="row" gap="1.5cqmin">
|
<Flex className="dns-hole-controls-item-controls" direction="row" gap="lg">
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
className="dns-hole-controls-item-toggle-button"
|
className="dns-hole-controls-item-toggle-button"
|
||||||
disabled={!controlEnabled}
|
disabled={!controlEnabled}
|
||||||
@@ -343,20 +342,18 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
|
className={`dns-hole-controls-item-toggle-button-styling${controlEnabled ? " hoverable-component clickable-component" : ""}`}
|
||||||
bd="0.1cqmin solid var(--border-color)"
|
bd="1px solid var(--border-color)"
|
||||||
px="2.5cqmin"
|
px="sm"
|
||||||
h="7.5cqmin"
|
h="lg"
|
||||||
fz="4.5cqmin"
|
|
||||||
lts="0.1cqmin"
|
|
||||||
color="var(--background-color)"
|
color="var(--background-color)"
|
||||||
c="var(--mantine-color-text)"
|
c="var(--mantine-color-text)"
|
||||||
styles={{ section: { marginInlineEnd: "2.5cqmin" }, root: { cursor: "inherit" } }}
|
styles={{ section: { marginInlineEnd: "sm" }, root: { cursor: "inherit" } }}
|
||||||
leftSection={
|
leftSection={
|
||||||
isConnected && (
|
isConnected && (
|
||||||
<IconCircleFilled
|
<IconCircleFilled
|
||||||
className="dns-hole-controls-item-status-icon"
|
className="dns-hole-controls-item-status-icon"
|
||||||
color={dnsLightStatus(isEnabled)}
|
color={dnsLightStatus(isEnabled)}
|
||||||
style={{ height: "3.5cqmin", width: "3.5cqmin" }}
|
size={16}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -374,27 +371,25 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<ActionIcon
|
|
||||||
className="dns-hole-controls-item-timer-button"
|
|
||||||
display={isInteractPermitted ? undefined : "none"}
|
|
||||||
disabled={!controlEnabled || !isEnabled}
|
|
||||||
color="yellow"
|
|
||||||
size="fit-content"
|
|
||||||
radius="999px 999px 0px 999px"
|
|
||||||
bd={0}
|
|
||||||
variant="subtle"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedIntegrationIds([data.integration.id]);
|
|
||||||
open();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconClockPause
|
|
||||||
className="dns-hole-controls-item-timer-icon"
|
|
||||||
style={{ height: "7.5cqmin", width: "7.5cqmin" }}
|
|
||||||
/>
|
|
||||||
</ActionIcon>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<ActionIcon
|
||||||
|
className="dns-hole-controls-item-timer-button"
|
||||||
|
display={isInteractPermitted ? undefined : "none"}
|
||||||
|
disabled={!controlEnabled || !isEnabled}
|
||||||
|
color="yellow"
|
||||||
|
size={30}
|
||||||
|
radius={board.itemRadius}
|
||||||
|
bd={0}
|
||||||
|
ms={"auto"}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedIntegrationIds([data.integration.id]);
|
||||||
|
open();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconClockPause className="dns-hole-controls-item-timer-icon" size={20} />
|
||||||
|
</ActionIcon>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { BoxProps } from "@mantine/core";
|
import type { BoxProps } from "@mantine/core";
|
||||||
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
|
import { Avatar, AvatarGroup, Card, Flex, SimpleGrid, Stack, Text, Tooltip, TooltipFloating } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { formatNumber } from "@homarr/common";
|
import { formatNumber } from "@homarr/common";
|
||||||
import { integrationDefs } from "@homarr/definitions";
|
import { integrationDefs } from "@homarr/definitions";
|
||||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||||
@@ -62,26 +63,26 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
|||||||
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" {...boxPropsByLayout(options.layout)} p="2cqmin">
|
<SimpleGrid cols={2} h="100%" p={"xs"} {...boxPropsByLayout(options.layout)}>
|
||||||
{data.length > 0 ? (
|
{data.length > 0 ? (
|
||||||
stats.map((item) => (
|
stats.map((item) => (
|
||||||
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Stack h="100%" w="100%" justify="center" align="center" gap="2.5cqmin" p="2.5cqmin">
|
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
|
||||||
<AvatarGroup spacing="10cqmin">
|
<AvatarGroup spacing="md">
|
||||||
{summaries.map(({ integration }) => (
|
{summaries.map(({ integration }) => (
|
||||||
<Tooltip key={integration.id} label={integration.name}>
|
<Tooltip key={integration.id} label={integration.name}>
|
||||||
<Avatar h="35cqmin" w="35cqmin" src={integrationDefs[integration.kind].iconUrl} />
|
<Avatar h={30} w={30} src={integrationDefs[integration.kind].iconUrl} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</AvatarGroup>
|
</AvatarGroup>
|
||||||
<Text fz="10cqmin" ta="center">
|
<Text fz="md" ta="center">
|
||||||
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
{t("widget.dnsHoleSummary.error.integrationsDisconnected")}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,30 +153,30 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
|||||||
const { ref, height, width } = useElementSize();
|
const { ref, height, width } = useElementSize();
|
||||||
const isLong = width > height + 20;
|
const isLong = width > height + 20;
|
||||||
const tooltip = item.tooltip?.(data, t);
|
const tooltip = item.tooltip?.(data, t);
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
|
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
|
||||||
<Card
|
<Card
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="summary-card"
|
className="summary-card"
|
||||||
m="2cqmin"
|
p="sm"
|
||||||
p="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}
|
}}
|
||||||
withBorder
|
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
className="summary-card-elements"
|
className="summary-card-elements"
|
||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
align="center"
|
align="center"
|
||||||
justify="space-evenly"
|
justify="center"
|
||||||
direction={isLong ? "row" : "column"}
|
direction={isLong ? "row" : "column"}
|
||||||
style={{ containerType: "size" }}
|
style={{ containerType: "size" }}
|
||||||
>
|
>
|
||||||
<item.icon className="summary-card-icon" size="40cqmin" style={{ margin: "2.5cqmin" }} />
|
<item.icon className="summary-card-icon" size={50} />
|
||||||
<Flex
|
<Flex
|
||||||
className="summary-card-texts"
|
className="summary-card-texts"
|
||||||
justify="center"
|
justify="center"
|
||||||
@@ -183,22 +184,15 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
|
|||||||
style={{
|
style={{
|
||||||
flex: isLong ? 1 : undefined,
|
flex: isLong ? 1 : undefined,
|
||||||
}}
|
}}
|
||||||
|
mt={"xs"}
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
gap={0}
|
||||||
gap="1cqmin"
|
|
||||||
>
|
>
|
||||||
<Text
|
<Text key={item.value(data)} className="summary-card-value text-flash" ta="center" size="lg" fw="bold">
|
||||||
key={item.value(data)}
|
|
||||||
className="summary-card-value text-flash"
|
|
||||||
ta="center"
|
|
||||||
size="20cqmin"
|
|
||||||
fw="bold"
|
|
||||||
style={{ "--glow-size": "2.5cqmin" }}
|
|
||||||
>
|
|
||||||
{item.value(data)}
|
{item.value(data)}
|
||||||
</Text>
|
</Text>
|
||||||
{item.label && (
|
{item.label && (
|
||||||
<Text className="summary-card-label" ta="center" size="15cqmin">
|
<Text className="summary-card-label" ta="center" size="md">
|
||||||
{translateIfNecessary(t, item.label)}
|
{translateIfNecessary(t, item.label)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ export default function DownloadClientsWidget({
|
|||||||
<Stack gap={0} h="100%" display="flex" style={baseStyle}>
|
<Stack gap={0} h="100%" display="flex" style={baseStyle}>
|
||||||
<MantineReactTable table={table} />
|
<MantineReactTable table={table} />
|
||||||
<Group
|
<Group
|
||||||
h="var(--ratio-width)"
|
h={40}
|
||||||
px="var(--space-size)"
|
px="var(--space-size)"
|
||||||
justify={integrationTypes.includes("torrent") ? "space-between" : "end"}
|
justify={integrationTypes.includes("torrent") ? "space-between" : "end"}
|
||||||
style={{
|
style={{
|
||||||
@@ -690,7 +690,6 @@ export default function DownloadClientsWidget({
|
|||||||
)}
|
)}
|
||||||
<ClientsControl
|
<ClientsControl
|
||||||
clients={clients}
|
clients={clients}
|
||||||
style={editStyle}
|
|
||||||
filters={quickFilters}
|
filters={quickFilters}
|
||||||
setFilters={setQuickFilters}
|
setFilters={setQuickFilters}
|
||||||
availableStatuses={availableStatuses}
|
availableStatuses={availableStatuses}
|
||||||
@@ -785,10 +784,9 @@ interface ClientsControlProps {
|
|||||||
filters: QuickFilter;
|
filters: QuickFilter;
|
||||||
setFilters: (filters: QuickFilter) => void;
|
setFilters: (filters: QuickFilter) => void;
|
||||||
availableStatuses: QuickFilter["statuses"];
|
availableStatuses: QuickFilter["statuses"];
|
||||||
style?: MantineStyleProp;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ClientsControl = ({ clients, filters, setFilters, availableStatuses, style }: ClientsControlProps) => {
|
const ClientsControl = ({ clients, filters, setFilters, availableStatuses }: ClientsControlProps) => {
|
||||||
const integrationsStatuses = clients.reduce(
|
const integrationsStatuses = clients.reduce(
|
||||||
(acc, { status, integration: { id }, interact }) =>
|
(acc, { status, integration: { id }, interact }) =>
|
||||||
status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc,
|
status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc,
|
||||||
@@ -799,33 +797,21 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses, style
|
|||||||
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
|
clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 0),
|
||||||
"/s",
|
"/s",
|
||||||
);
|
);
|
||||||
const chipStyle = {
|
|
||||||
"--chip-fz": "var(--button-fz)",
|
|
||||||
"--chip-size": "calc(var(--ratio-width) * 0.9)",
|
|
||||||
"--chip-icon-size": "calc(var(--chip-fz)*2/3)",
|
|
||||||
"--chip-padding": "var(--chip-fz)",
|
|
||||||
"--chip-checked-padding": "var(--chip-icon-size)",
|
|
||||||
"--chip-spacing": "var(--space-size)",
|
|
||||||
};
|
|
||||||
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation();
|
const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation();
|
||||||
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation();
|
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation();
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const t = useScopedI18n("widget.downloads");
|
const t = useScopedI18n("widget.downloads");
|
||||||
return (
|
return (
|
||||||
<Group gap="var(--space-size)" style={style}>
|
<Group gap={5}>
|
||||||
<Popover withinPortal={false} offset={0}>
|
<Popover withinPortal={false} offset={0}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon size="var(--button-size)" radius={999} variant="light">
|
<ActionIcon size={30} radius={999} variant="light">
|
||||||
<IconFilter style={actionIconIconStyle} />
|
<IconFilter style={actionIconIconStyle} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown
|
<Popover.Dropdown>
|
||||||
w="calc(var(--ratio-width)*4)"
|
<Stack gap="md" align="center" pb="var(--space-size)">
|
||||||
p="var(--space-size)"
|
|
||||||
bg="var(--background-color)"
|
|
||||||
style={{ "--popover-border-color": "var(--border-color)" }}
|
|
||||||
>
|
|
||||||
<Stack gap="var(--space-size)" align="center" pb="var(--space-size)">
|
|
||||||
<Text fw="700">{t("items.integration.columnTitle")}</Text>
|
<Text fw="700">{t("items.integration.columnTitle")}</Text>
|
||||||
<Chip.Group
|
<Chip.Group
|
||||||
multiple
|
multiple
|
||||||
@@ -833,7 +819,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses, style
|
|||||||
onChange={(names) => setFilters({ ...filters, integrationKinds: names })}
|
onChange={(names) => setFilters({ ...filters, integrationKinds: names })}
|
||||||
>
|
>
|
||||||
{clients.map(({ integration }) => (
|
{clients.map(({ integration }) => (
|
||||||
<Chip style={chipStyle} key={integration.id} value={integration.name}>
|
<Chip key={integration.id} value={integration.name}>
|
||||||
{integration.name}
|
{integration.name}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
@@ -845,7 +831,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses, style
|
|||||||
onChange={(statuses) => setFilters({ ...filters, statuses: statuses as typeof filters.statuses })}
|
onChange={(statuses) => setFilters({ ...filters, statuses: statuses as typeof filters.statuses })}
|
||||||
>
|
>
|
||||||
{availableStatuses.map((status) => (
|
{availableStatuses.map((status) => (
|
||||||
<Chip style={chipStyle} key={status} value={status}>
|
<Chip key={status} value={status}>
|
||||||
{t(`states.${status}`)}
|
{t(`states.${status}`)}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
@@ -861,7 +847,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses, style
|
|||||||
{someInteract && (
|
{someInteract && (
|
||||||
<Tooltip label={t("actions.clients.resume")}>
|
<Tooltip label={t("actions.clients.resume")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="var(--button-size)"
|
size={30}
|
||||||
radius={999}
|
radius={999}
|
||||||
disabled={integrationsStatuses.paused.length === 0}
|
disabled={integrationsStatuses.paused.length === 0}
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -885,7 +871,7 @@ const ClientsControl = ({ clients, filters, setFilters, availableStatuses, style
|
|||||||
{someInteract && (
|
{someInteract && (
|
||||||
<Tooltip label={t("actions.clients.pause")}>
|
<Tooltip label={t("actions.clients.pause")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="var(--button-size)"
|
size={30}
|
||||||
radius={999}
|
radius={999}
|
||||||
disabled={integrationsStatuses.active.length === 0}
|
disabled={integrationsStatuses.active.length === 0}
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -981,8 +967,8 @@ const ClientAvatar = ({ client }: ClientAvatarProps) => {
|
|||||||
key={client.integration.id}
|
key={client.integration.id}
|
||||||
src={getIconUrl(client.integration.kind)}
|
src={getIconUrl(client.integration.kind)}
|
||||||
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
|
style={{ filter: !isConnected ? "grayscale(100%)" : undefined }}
|
||||||
size="var(--image-size)"
|
size={30}
|
||||||
p="calc(var(--space-size)*0.5)"
|
p={5}
|
||||||
bd={`calc(var(--space-size)*0.5) solid ${client.status ? "transparent" : "var(--mantine-color-red-filled)"}`}
|
bd={`calc(var(--space-size)*0.5) solid ${client.status ? "transparent" : "var(--mantine-color-red-filled)"}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
33
packages/widgets/src/health-monitoring/rings/cpu-ring.tsx
Normal file
33
packages/widgets/src/health-monitoring/rings/cpu-ring.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Box, Center, RingProgress, Text } from "@mantine/core";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
import { IconCpu } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { progressColor } from "../system-health";
|
||||||
|
|
||||||
|
export const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-cpu-utilization"
|
||||||
|
roundCaps
|
||||||
|
size={fallbackWidth * 0.95}
|
||||||
|
thickness={fallbackWidth / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-cpu-utilization-value" size="sm">{`${cpuUtilization.toFixed(2)}%`}</Text>
|
||||||
|
<IconCpu className="health-monitoring-cpu-utilization-icon" size={30} />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: Number(cpuUtilization.toFixed(2)),
|
||||||
|
color: progressColor(Number(cpuUtilization.toFixed(2))),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Box, Center, RingProgress, Text } from "@mantine/core";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
import { IconCpu } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { progressColor } from "../system-health";
|
||||||
|
|
||||||
|
export const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number | undefined }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196
|
||||||
|
|
||||||
|
if (!cpuTemp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-cpu-temp"
|
||||||
|
roundCaps
|
||||||
|
size={fallbackWidth * 0.95}
|
||||||
|
thickness={fallbackWidth / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-cpu-temp-value" size="sm">
|
||||||
|
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
||||||
|
</Text>
|
||||||
|
<IconCpu className="health-monitoring-cpu-temp-icon" size={30} />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: cpuTemp,
|
||||||
|
color: progressColor(cpuTemp),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
54
packages/widgets/src/health-monitoring/rings/memory-ring.tsx
Normal file
54
packages/widgets/src/health-monitoring/rings/memory-ring.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Box, Center, RingProgress, Text } from "@mantine/core";
|
||||||
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
import { IconBrain } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { progressColor } from "../system-health";
|
||||||
|
|
||||||
|
export const MemoryRing = ({ available, used }: { available: string; used: string }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196
|
||||||
|
const memoryUsage = formatMemoryUsage(available, used);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-memory-use"
|
||||||
|
roundCaps
|
||||||
|
size={fallbackWidth * 0.95}
|
||||||
|
thickness={fallbackWidth / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-memory-value" size="sm">
|
||||||
|
{memoryUsage.memUsed.GB}GiB
|
||||||
|
</Text>
|
||||||
|
<IconBrain className="health-monitoring-memory-icon" size={30} />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: Number(memoryUsage.memUsed.percent),
|
||||||
|
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
||||||
|
tooltip: `${memoryUsage.memUsed.percent}%`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
||||||
|
const memFreeBytes = Number(memFree);
|
||||||
|
const memUsedBytes = Number(memUsed);
|
||||||
|
const totalMemory = memFreeBytes + memUsedBytes;
|
||||||
|
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
|
||||||
|
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
|
||||||
|
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
|
||||||
|
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
|
||||||
|
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
||||||
|
|
||||||
|
return {
|
||||||
|
memFree: { percent: memFreePercent, GB: memFreeGB },
|
||||||
|
memUsed: { percent: memUsedPercent, GB: memUsedGB },
|
||||||
|
memTotal: { GB: memTotalGB },
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[data-mantine-color-scheme="light"] .card {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .card {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
ActionIcon,
|
||||||
Box,
|
Box,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
|
||||||
Divider,
|
Divider,
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
@@ -12,12 +11,11 @@ import {
|
|||||||
List,
|
List,
|
||||||
Modal,
|
Modal,
|
||||||
Progress,
|
Progress,
|
||||||
RingProgress,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure, useElementSize } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBrain,
|
IconBrain,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -29,14 +27,20 @@ import {
|
|||||||
IconTemperature,
|
IconTemperature,
|
||||||
IconVersions,
|
IconVersions,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import combineClasses from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import duration from "dayjs/plugin/duration";
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { CpuRing } from "./rings/cpu-ring";
|
||||||
|
import { CpuTempRing } from "./rings/cpu-temp-ring";
|
||||||
|
import { formatMemoryUsage, MemoryRing } from "./rings/memory-ring";
|
||||||
|
import classes from "./system-health.module.css";
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -55,6 +59,7 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
|
|||||||
);
|
);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
|
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
@@ -75,23 +80,21 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
|
<Stack h="100%" gap="sm" className="health-monitoring">
|
||||||
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
|
||||||
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
||||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
gap="2.5cqmin"
|
gap="sm"
|
||||||
key={integrationId}
|
key={integrationId}
|
||||||
h="100%"
|
h="100%"
|
||||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||||
p="2.5cqmin"
|
p="sm"
|
||||||
>
|
>
|
||||||
<Card className="health-monitoring-information-card" p="2.5cqmin" withBorder>
|
<Box className="health-monitoring-information-card" p="sm">
|
||||||
<Flex
|
<Flex
|
||||||
className="health-monitoring-information-card-elements"
|
className="health-monitoring-information-card-elements"
|
||||||
h="100%"
|
|
||||||
w="100%"
|
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
key={integrationId}
|
key={integrationId}
|
||||||
@@ -103,13 +106,19 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
|
|||||||
processing
|
processing
|
||||||
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
color={healthInfo.rebootRequired ? "red" : healthInfo.availablePkgUpdates > 0 ? "blue" : "gray"}
|
||||||
position="top-end"
|
position="top-end"
|
||||||
size="4cqmin"
|
size="md"
|
||||||
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
|
||||||
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
|
||||||
>
|
>
|
||||||
<Avatar className="health-monitoring-information-icon-avatar" size="10cqmin" radius="sm">
|
<ActionIcon
|
||||||
<IconInfoCircle className="health-monitoring-information-icon" size="8cqmin" onClick={open} />
|
className="health-monitoring-information-icon-avatar"
|
||||||
</Avatar>
|
variant={"light"}
|
||||||
|
color="var(--mantine-color-text)"
|
||||||
|
size={40}
|
||||||
|
radius={board.itemRadius}
|
||||||
|
>
|
||||||
|
<IconInfoCircle className="health-monitoring-information-icon" size={30} onClick={open} />
|
||||||
|
</ActionIcon>
|
||||||
</Indicator>
|
</Indicator>
|
||||||
<Modal
|
<Modal
|
||||||
opened={opened}
|
opened={opened}
|
||||||
@@ -120,49 +129,31 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
|
|||||||
>
|
>
|
||||||
<Stack gap="10px" className="health-monitoring-modal-stack">
|
<Stack gap="10px" className="health-monitoring-modal-stack">
|
||||||
<Divider />
|
<Divider />
|
||||||
<List className="health-monitoring-information-list" center spacing="0.5cqmin">
|
<List className="health-monitoring-information-list" center spacing="xs">
|
||||||
<List.Item
|
<List.Item className="health-monitoring-information-processor" icon={<IconCpu2 size={30} />}>
|
||||||
className="health-monitoring-information-processor"
|
|
||||||
icon={<IconCpu2 size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
|
||||||
className="health-monitoring-information-memory"
|
|
||||||
icon={<IconBrain size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item className="health-monitoring-information-memory" icon={<IconBrain size={30} />}>
|
||||||
className="health-monitoring-information-memory"
|
|
||||||
icon={<IconBrain size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
||||||
memoryAvailable: memoryUsage.memFree.GB,
|
memoryAvailable: memoryUsage.memFree.GB,
|
||||||
percent: memoryUsage.memFree.percent,
|
percent: memoryUsage.memFree.percent,
|
||||||
})}
|
})}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item className="health-monitoring-information-version" icon={<IconVersions size={30} />}>
|
||||||
className="health-monitoring-information-version"
|
|
||||||
icon={<IconVersions size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.version", {
|
{t("widget.healthMonitoring.popover.version", {
|
||||||
version: healthInfo.version,
|
version: healthInfo.version,
|
||||||
})}
|
})}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item className="health-monitoring-information-uptime" icon={<IconClock size={30} />}>
|
||||||
className="health-monitoring-information-uptime"
|
|
||||||
icon={<IconClock size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{formatUptime(healthInfo.uptime, t)}
|
{formatUptime(healthInfo.uptime, t)}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item className="health-monitoring-information-load-average" icon={<IconCpu size={30} />}>
|
||||||
className="health-monitoring-information-load-average"
|
|
||||||
icon={<IconCpu size="1.5cqmin" />}
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.loadAverage")}
|
{t("widget.healthMonitoring.popover.loadAverage")}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
|
<List m="xs" withPadding center spacing="xs" icon={<IconCpu size={30} />}>
|
||||||
<List.Item className="health-monitoring-information-load-average-1min">
|
<List.Item className="health-monitoring-information-load-average-1min">
|
||||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
||||||
</List.Item>
|
</List.Item>
|
||||||
@@ -184,56 +175,53 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
|
|||||||
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
{
|
{
|
||||||
<Text
|
<Text className="health-monitoring-status-update-time" c="dimmed" size="sm" ta="center">
|
||||||
className="health-monitoring-status-update-time"
|
|
||||||
c="dimmed"
|
|
||||||
size="3.5cqmin"
|
|
||||||
ta="center"
|
|
||||||
mb="2.5cqmin"
|
|
||||||
>
|
|
||||||
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(updatedAt).fromNow() })}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
</Card>
|
</Box>
|
||||||
{options.fileSystem &&
|
{options.fileSystem &&
|
||||||
disksData.map((disk) => {
|
disksData.map((disk) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
|
className={combineClasses(
|
||||||
|
`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`,
|
||||||
|
classes.card,
|
||||||
|
)}
|
||||||
key={disk.deviceName}
|
key={disk.deviceName}
|
||||||
p="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
withBorder
|
p="sm"
|
||||||
>
|
>
|
||||||
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" m="1.5cqmin">
|
<Flex className="health-monitoring-disk-status" justify="space-between" align="center" mb="sm">
|
||||||
<Group gap="1cqmin">
|
<Group gap="xs">
|
||||||
<IconServer className="health-monitoring-disk-icon" size="5cqmin" />
|
<IconServer className="health-monitoring-disk-icon" size="1rem" />
|
||||||
<Text className="dihealth-monitoring-disk-name" size="4cqmin">
|
<Text className="dihealth-monitoring-disk-name" size={"md"}>
|
||||||
{disk.deviceName}
|
{disk.deviceName}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="1cqmin">
|
<Group gap="xs">
|
||||||
<IconTemperature className="health-monitoring-disk-temperature-icon" size="5cqmin" />
|
<IconTemperature className="health-monitoring-disk-temperature-icon" size="1rem" />
|
||||||
<Text className="health-monitoring-disk-temperature-value" size="4cqmin">
|
<Text className="health-monitoring-disk-temperature-value" size="md">
|
||||||
{options.fahrenheit
|
{options.fahrenheit
|
||||||
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
|
||||||
: `${disk.temperature}°C`}
|
: `${disk.temperature}°C`}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="1cqmin">
|
<Group gap="xs">
|
||||||
<IconFileReport className="health-monitoring-disk-status-icon" size="5cqmin" />
|
<IconFileReport className="health-monitoring-disk-status-icon" size="1rem" />
|
||||||
<Text className="health-monitoring-disk-status-value" size="4cqmin">
|
<Text className="health-monitoring-disk-status-value" size="md">
|
||||||
{disk.overallStatus ? disk.overallStatus : "N/A"}
|
{disk.overallStatus ? disk.overallStatus : "N/A"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Progress.Root className="health-monitoring-disk-use" h="6cqmin">
|
<Progress.Root className="health-monitoring-disk-use" radius={board.itemRadius} h="md">
|
||||||
<Tooltip label={disk.used}>
|
<Tooltip label={disk.used}>
|
||||||
<Progress.Section
|
<Progress.Section
|
||||||
value={disk.percentage}
|
value={disk.percentage}
|
||||||
color={progressColor(disk.percentage)}
|
color={progressColor(disk.percentage)}
|
||||||
className="health-monitoring-disk-use-percentage"
|
className="health-monitoring-disk-use-percentage"
|
||||||
>
|
>
|
||||||
<Progress.Label className="health-monitoring-disk-use-value" fz="2.5cqmin">
|
<Progress.Label className="health-monitoring-disk-use-value" fz="xs">
|
||||||
{t("widget.healthMonitoring.popover.used")}
|
{t("widget.healthMonitoring.popover.used")}
|
||||||
</Progress.Label>
|
</Progress.Label>
|
||||||
</Progress.Section>
|
</Progress.Section>
|
||||||
@@ -251,7 +239,7 @@ export const SystemHealthMonitoring = ({ options, integrationIds }: WidgetCompon
|
|||||||
value={100 - disk.percentage}
|
value={100 - disk.percentage}
|
||||||
color="default"
|
color="default"
|
||||||
>
|
>
|
||||||
<Progress.Label className="health-monitoring-disk-available-value" fz="2.5cqmin">
|
<Progress.Label className="health-monitoring-disk-available-value" fz="xs">
|
||||||
{t("widget.healthMonitoring.popover.available")}
|
{t("widget.healthMonitoring.popover.available")}
|
||||||
</Progress.Label>
|
</Progress.Label>
|
||||||
</Progress.Section>
|
</Progress.Section>
|
||||||
@@ -314,117 +302,3 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm
|
|||||||
})
|
})
|
||||||
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
|
.sort((fileSystemA, fileSystemB) => fileSystemA.deviceName.localeCompare(fileSystemB.deviceName));
|
||||||
};
|
};
|
||||||
|
|
||||||
const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => {
|
|
||||||
const { width, ref } = useElementSize();
|
|
||||||
const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-cpu-utilization"
|
|
||||||
roundCaps
|
|
||||||
size={fallbackWidth * 0.95}
|
|
||||||
thickness={fallbackWidth / 10}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text
|
|
||||||
className="health-monitoring-cpu-utilization-value"
|
|
||||||
size="3cqmin"
|
|
||||||
>{`${cpuUtilization.toFixed(2)}%`}</Text>
|
|
||||||
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: Number(cpuUtilization.toFixed(2)),
|
|
||||||
color: progressColor(Number(cpuUtilization.toFixed(2))),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number | undefined }) => {
|
|
||||||
const { width, ref } = useElementSize();
|
|
||||||
const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196
|
|
||||||
|
|
||||||
if (!cpuTemp) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-cpu-temp"
|
|
||||||
roundCaps
|
|
||||||
size={fallbackWidth * 0.95}
|
|
||||||
thickness={fallbackWidth / 10}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
|
|
||||||
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
|
||||||
</Text>
|
|
||||||
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: cpuTemp,
|
|
||||||
color: progressColor(cpuTemp),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoryRing = ({ available, used }: { available: string; used: string }) => {
|
|
||||||
const { width, ref } = useElementSize();
|
|
||||||
const fallbackWidth = width || 1; // See https://github.com/homarr-labs/homarr/issues/2196
|
|
||||||
const memoryUsage = formatMemoryUsage(available, used);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-memory-use"
|
|
||||||
roundCaps
|
|
||||||
size={fallbackWidth * 0.95}
|
|
||||||
thickness={fallbackWidth / 10}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text className="health-monitoring-memory-value" size="3cqmin">
|
|
||||||
{memoryUsage.memUsed.GB}GiB
|
|
||||||
</Text>
|
|
||||||
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: Number(memoryUsage.memUsed.percent),
|
|
||||||
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
|
||||||
tooltip: `${memoryUsage.memUsed.percent}%`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
|
||||||
const memFreeBytes = Number(memFree);
|
|
||||||
const memUsedBytes = Number(memUsed);
|
|
||||||
const totalMemory = memFreeBytes + memUsedBytes;
|
|
||||||
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
|
|
||||||
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
|
|
||||||
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
|
|
||||||
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
|
|
||||||
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
|
|
||||||
|
|
||||||
return {
|
|
||||||
memFree: { percent: memFreePercent, GB: memFreeGB },
|
|
||||||
memUsed: { percent: memUsedPercent, GB: memUsedGB },
|
|
||||||
memTotal: { GB: memTotalGB },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[data-mantine-color-scheme="light"] .card {
|
||||||
|
background-color: var(--mantine-color-gray-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"] .card {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core";
|
import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core";
|
||||||
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
|
||||||
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
export default function IndexerManagerWidget({ options, integrationIds }: WidgetComponentProps<"indexerManager">) {
|
export default function IndexerManagerWidget({ options, integrationIds }: WidgetComponentProps<"indexerManager">) {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -22,6 +25,7 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget
|
|||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
|
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
clientApi.widget.indexerManager.subscribeIndexersStatus.useSubscription(
|
clientApi.widget.indexerManager.subscribeIndexersStatus.useSubscription(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
@@ -36,21 +40,28 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const iconStyle = { height: "7.5cqmin", width: "7.5cqmin" };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex className="indexer-manager-container" h="100%" direction="column" gap="2.5cqmin" p="2.5cqmin" align="center">
|
<Flex className="indexer-manager-container" h="100%" direction="column" gap="sm" p="sm" align="center">
|
||||||
<Text className="indexer-manager-title" size="6.5cqmin">
|
<Flex className="indexer-manager-title" align={"center"} gap={"xs"}>
|
||||||
<IconReportSearch className="indexer-manager-title-icon" size="7cqmin" /> {t("widget.indexerManager.title")}
|
<IconReportSearch className="indexer-manager-title-icon" size={30} />
|
||||||
</Text>
|
<Text size="md" fw={"bold"}>
|
||||||
<Card className="indexer-manager-list-container" w="100%" p="2.5cqmin" radius="md" flex={1} withBorder>
|
{t("widget.indexerManager.title")}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Card
|
||||||
|
className={combineClasses("indexer-manager-list-container", classes.card)}
|
||||||
|
w="100%"
|
||||||
|
p="sm"
|
||||||
|
radius={board.itemRadius}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
<ScrollArea className="indexer-manager-list-scroll-area" h="100%">
|
<ScrollArea className="indexer-manager-list-scroll-area" h="100%">
|
||||||
{indexersData.map(({ integrationId, indexers }) => (
|
{indexersData.map(({ integrationId, indexers }) => (
|
||||||
<Container className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
<Container className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
|
||||||
{indexers.map((indexer) => (
|
{indexers.map((indexer) => (
|
||||||
<Group
|
<Group
|
||||||
className={`indexer-manager-line indexer-manager-${indexer.name}`}
|
className={`indexer-manager-line indexer-manager-${indexer.name}`}
|
||||||
h="7.5cqmin"
|
h={30}
|
||||||
key={indexer.id}
|
key={indexer.id}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
>
|
>
|
||||||
@@ -59,7 +70,7 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget
|
|||||||
href={indexer.url}
|
href={indexer.url}
|
||||||
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
|
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
|
||||||
>
|
>
|
||||||
<Text className="indexer-manager-line-anchor-text" c="dimmed" size="5cqmin">
|
<Text className="indexer-manager-line-anchor-text" c="dimmed" size="md">
|
||||||
{indexer.name}
|
{indexer.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
@@ -67,13 +78,13 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget
|
|||||||
<IconCircleX
|
<IconCircleX
|
||||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
|
||||||
color="#d9534f"
|
color="#d9534f"
|
||||||
style={iconStyle}
|
size={24}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<IconCircleCheck
|
<IconCircleCheck
|
||||||
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
|
||||||
color="#2ecc71"
|
color="#2ecc71"
|
||||||
style={iconStyle}
|
size={24}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -85,11 +96,9 @@ export default function IndexerManagerWidget({ options, integrationIds }: Widget
|
|||||||
<Button
|
<Button
|
||||||
className="indexer-manager-test-button"
|
className="indexer-manager-test-button"
|
||||||
w="100%"
|
w="100%"
|
||||||
fz="5cqmin"
|
radius={board.itemRadius}
|
||||||
h="12.5cqmin"
|
|
||||||
radius="md"
|
|
||||||
variant="light"
|
variant="light"
|
||||||
leftSection={<IconTestPipe style={iconStyle} />}
|
leftSection={<IconTestPipe size={"1rem"} />}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
loaderProps={{ type: "dots" }}
|
loaderProps={{ type: "dots" }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stac
|
|||||||
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
import { IconThumbDown, IconThumbUp } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { MediaAvailability, MediaRequestStatus } from "@homarr/integrations/types";
|
import { MediaAvailability, MediaRequestStatus } from "@homarr/integrations/types";
|
||||||
import type { ScopedTranslationFunction } from "@homarr/translation";
|
import type { ScopedTranslationFunction } from "@homarr/translation";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
@@ -54,23 +55,23 @@ export default function MediaServerWidget({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
|
const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation();
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
className="mediaRequests-list-scrollArea"
|
className="mediaRequests-list-scrollArea"
|
||||||
scrollbarSize="2cqmin"
|
scrollbarSize="md"
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
|
<Stack className="mediaRequests-list-list" gap="sm" p="sm">
|
||||||
{mediaRequests.map((mediaRequest) => (
|
{mediaRequests.map((mediaRequest) => (
|
||||||
<Card
|
<Card
|
||||||
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
|
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
|
||||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||||
h="20cqmin"
|
radius={board.itemRadius}
|
||||||
radius="2cqmin"
|
p="sm"
|
||||||
p="2cqmin"
|
|
||||||
withBorder
|
withBorder
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -93,28 +94,24 @@ export default function MediaServerWidget({
|
|||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
gap={0}
|
gap={0}
|
||||||
>
|
>
|
||||||
<Group className="mediaRequests-list-item-left-side" h="100%" gap="4cqmin" wrap="nowrap" flex={1}>
|
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1}>
|
||||||
<Image
|
<Image
|
||||||
className="mediaRequests-list-item-poster"
|
className="mediaRequests-list-item-poster"
|
||||||
src={mediaRequest.posterImagePath}
|
src={mediaRequest.posterImagePath}
|
||||||
h="100%"
|
h={60}
|
||||||
w="10cqmin"
|
w="auto"
|
||||||
radius="1cqmin"
|
radius={"md"}
|
||||||
/>
|
/>
|
||||||
<Stack className="mediaRequests-list-item-media-infos" gap="1cqmin">
|
<Stack className="mediaRequests-list-item-media-infos" gap={0}>
|
||||||
<Group className="mediaRequests-list-item-info-first-line" gap="2cqmin" wrap="nowrap">
|
<Group className="mediaRequests-list-item-info-first-line" gap="sm" align={"end"} wrap="nowrap">
|
||||||
<Text className="mediaRequests-list-item-media-year" size="3.5cqmin" pt="0.75cqmin">
|
<Text className="mediaRequests-list-item-media-year" size="md" pt="xs">
|
||||||
{mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")}
|
{mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
<Badge
|
||||||
className="mediaRequests-list-item-media-status"
|
className="mediaRequests-list-item-media-status"
|
||||||
color={getAvailabilityProperties(mediaRequest.availability, t).color}
|
color={getAvailabilityProperties(mediaRequest.availability, t).color}
|
||||||
variant="light"
|
variant="light"
|
||||||
fz="3.5cqmin"
|
size="md"
|
||||||
lh="4cqmin"
|
|
||||||
size="5cqmin"
|
|
||||||
pt="0.75cqmin"
|
|
||||||
px="2cqmin"
|
|
||||||
>
|
>
|
||||||
{getAvailabilityProperties(mediaRequest.availability, t).label}
|
{getAvailabilityProperties(mediaRequest.availability, t).label}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -124,26 +121,27 @@ export default function MediaServerWidget({
|
|||||||
href={mediaRequest.href}
|
href={mediaRequest.href}
|
||||||
c="var(--mantine-color-text)"
|
c="var(--mantine-color-text)"
|
||||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||||
fz="5cqmin"
|
fz="md"
|
||||||
|
fw={"bold"}
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
>
|
>
|
||||||
{mediaRequest.name || "unknown"}
|
{mediaRequest.name || "unknown"}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Stack className="mediaRequests-list-item-right-side" gap="1cqmin" align="end">
|
<Stack className="mediaRequests-list-item-right-side" gap="xs" ms={"lg"} align="end">
|
||||||
<Group className="mediaRequests-list-item-request-user" gap="2cqmin" wrap="nowrap">
|
<Group className="mediaRequests-list-item-request-user" gap="sm" wrap="nowrap">
|
||||||
<Avatar
|
<Avatar
|
||||||
className="mediaRequests-list-item-request-user-avatar"
|
className="mediaRequests-list-item-request-user-avatar"
|
||||||
src={mediaRequest.requestedBy?.avatar}
|
src={mediaRequest.requestedBy?.avatar}
|
||||||
size="6cqmin"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Anchor
|
<Anchor
|
||||||
className="mediaRequests-list-item-request-user-name"
|
className="mediaRequests-list-item-request-user-name"
|
||||||
href={mediaRequest.requestedBy?.link}
|
href={mediaRequest.requestedBy?.link}
|
||||||
c="var(--mantine-color-text)"
|
c="var(--mantine-color-text)"
|
||||||
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
target={options.linksTargetNewTab ? "_blank" : "_self"}
|
||||||
fz="5cqmin"
|
fz="md"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
style={{ wordBreak: "break-all" }}
|
style={{ wordBreak: "break-all" }}
|
||||||
>
|
>
|
||||||
@@ -151,13 +149,14 @@ export default function MediaServerWidget({
|
|||||||
</Anchor>
|
</Anchor>
|
||||||
</Group>
|
</Group>
|
||||||
{mediaRequest.status === MediaRequestStatus.PendingApproval ? (
|
{mediaRequest.status === MediaRequestStatus.PendingApproval ? (
|
||||||
<Group className="mediaRequests-list-item-pending-buttons" gap="2cqmin">
|
<Group className="mediaRequests-list-item-pending-buttons" gap="sm">
|
||||||
<Tooltip label={t("pending.approve")}>
|
<Tooltip label={t("pending.approve")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
className="mediaRequests-list-item-pending-button-approve"
|
className="mediaRequests-list-item-pending-button-approve"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="green"
|
color="green"
|
||||||
size="5cqmin"
|
size="md"
|
||||||
|
radius={"md"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateRequestAnswer({
|
mutateRequestAnswer({
|
||||||
integrationId: mediaRequest.integrationId,
|
integrationId: mediaRequest.integrationId,
|
||||||
@@ -166,7 +165,7 @@ export default function MediaServerWidget({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconThumbUp size="4cqmin" />
|
<IconThumbUp size={23} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={t("pending.decline")}>
|
<Tooltip label={t("pending.decline")}>
|
||||||
@@ -174,7 +173,8 @@ export default function MediaServerWidget({
|
|||||||
className="mediaRequests-list-item-pending-button-decline"
|
className="mediaRequests-list-item-pending-button-decline"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
size="5cqmin"
|
size="md"
|
||||||
|
radius={"md"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
mutateRequestAnswer({
|
mutateRequestAnswer({
|
||||||
integrationId: mediaRequest.integrationId,
|
integrationId: mediaRequest.integrationId,
|
||||||
@@ -183,7 +183,7 @@ export default function MediaServerWidget({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconThumbDown size="4cqmin" />
|
<IconThumbDown size={23} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.gridElement:not(:nth-child(8n)) {
|
[data-mantine-color-scheme="light"] .card {
|
||||||
border-right: 0.5cqmin solid var(--app-shell-border-color);
|
background-color: var(--mantine-color-gray-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gridElement:not(:nth-last-child(-n + 8)) {
|
[data-mantine-color-scheme="dark"] .card {
|
||||||
border-bottom: 0.5cqmin solid var(--app-shell-border-color);
|
background-color: var(--mantine-color-dark-7);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import type { RequestStats } from "@homarr/integrations/types";
|
import type { RequestStats } from "@homarr/integrations/types";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
@@ -42,9 +43,10 @@ export default function MediaServerWidget({
|
|||||||
|
|
||||||
const { width, height, ref } = useElementSize();
|
const { width, height, ref } = useElementSize();
|
||||||
|
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
|
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
|
||||||
|
|
||||||
//Add processing and available
|
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
name: "approved",
|
name: "approved",
|
||||||
@@ -93,37 +95,35 @@ export default function MediaServerWidget({
|
|||||||
className="mediaRequests-stats-layout"
|
className="mediaRequests-stats-layout"
|
||||||
display="flex"
|
display="flex"
|
||||||
h="100%"
|
h="100%"
|
||||||
gap="2cqmin"
|
gap="sm"
|
||||||
p="2cqmin"
|
p="sm"
|
||||||
align="center"
|
align="center"
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
>
|
>
|
||||||
<Text className="mediaRequests-stats-stats-title" size="6.5cqmin">
|
<Text className="mediaRequests-stats-stats-title" fw={"bold"} size="md">
|
||||||
{t("titles.stats.main")}
|
{t("titles.stats.main")}
|
||||||
</Text>
|
</Text>
|
||||||
<Grid className="mediaRequests-stats-stats-grid" gutter={0} w="100%">
|
<Grid className="mediaRequests-stats-stats-grid" gutter={10} w="100%">
|
||||||
{data.map((stat) => (
|
{data.map((stat) => (
|
||||||
<Grid.Col
|
<Grid.Col
|
||||||
className={combineClasses(
|
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||||
classes.gridElement,
|
|
||||||
"mediaRequests-stats-stat-wrapper",
|
|
||||||
`mediaRequests-stats-stat-${stat.name}`,
|
|
||||||
)}
|
|
||||||
key={stat.name}
|
key={stat.name}
|
||||||
span={3}
|
span={3}
|
||||||
>
|
>
|
||||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||||
<Stack className="mediaRequests-stats-stat-stack" align="center" gap="2cqmin" p="2cqmin">
|
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||||
<stat.icon className="mediaRequests-stats-stat-icon" size="7.5cqmin" />
|
<Stack className="mediaRequests-stats-stat-stack" align="center" gap={0} p="xs">
|
||||||
<Text className="mediaRequests-stats-stat-value" size="5cqmin">
|
<stat.icon className="mediaRequests-stats-stat-icon" size={30} />
|
||||||
{stat.number}
|
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||||
</Text>
|
{stat.number}
|
||||||
</Stack>
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Text className="mediaRequests-stats-users-title" size="6.5cqmin">
|
<Text className="mediaRequests-stats-users-title" fw={"bold"} size="md">
|
||||||
{t("titles.users.main")}
|
{t("titles.users.main")}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack
|
<Stack
|
||||||
@@ -132,36 +132,34 @@ export default function MediaServerWidget({
|
|||||||
w="100%"
|
w="100%"
|
||||||
ref={ref}
|
ref={ref}
|
||||||
display="flex"
|
display="flex"
|
||||||
gap="2cqmin"
|
gap="sm"
|
||||||
style={{ overflow: "hidden" }}
|
style={{ overflow: "hidden", justifyContent: "end" }}
|
||||||
>
|
>
|
||||||
{requestStats.users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
|
{requestStats.users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
|
||||||
<Card
|
<Card
|
||||||
className={combineClasses(
|
className={combineClasses(
|
||||||
"mediaRequests-stats-users-user-wrapper",
|
"mediaRequests-stats-users-user-wrapper",
|
||||||
`mediaRequests-stats-users-user-${user.id}`,
|
`mediaRequests-stats-users-user-${user.id}`,
|
||||||
|
classes.card,
|
||||||
)}
|
)}
|
||||||
key={user.id}
|
key={user.id}
|
||||||
withBorder
|
p="sm"
|
||||||
p="2cqmin"
|
radius={board.itemRadius}
|
||||||
flex={1}
|
|
||||||
mah="38.5cqmin"
|
|
||||||
radius="2.5cqmin"
|
|
||||||
>
|
>
|
||||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
|
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" display="flex">
|
||||||
<Tooltip label={user.integration.name}>
|
<Tooltip label={user.integration.name}>
|
||||||
<Avatar
|
<Avatar
|
||||||
className="mediaRequests-stats-users-user-avatar"
|
className="mediaRequests-stats-users-user-avatar"
|
||||||
size="12.5cqmin"
|
size="md"
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
bd={`0.5cqmin solid ${user.integration.kind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
bd={`2px solid ${user.integration.kind === "overseerr" ? "#ECB000" : "#6677CC"}`}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">
|
<Stack className="mediaRequests-stats-users-user-infos" gap={0}>
|
||||||
<Text className="mediaRequests-stats-users-user-userName" size="6cqmin">
|
<Text className="mediaRequests-stats-users-user-userName" fw={"bold"} size="md">
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="mediaRequests-stats-users-user-request-count" size="4cqmin">
|
<Text className="mediaRequests-stats-users-user-request-count" size="md">
|
||||||
{`${t("titles.users.requests")}: ${user.requestCount}`}
|
{`${t("titles.users.requests")}: ${user.requestCount}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -170,11 +168,12 @@ export default function MediaServerWidget({
|
|||||||
className="mediaRequests-stats-users-user-link-button"
|
className="mediaRequests-stats-users-user-link-button"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="var(--mantine-color-text)"
|
color="var(--mantine-color-text)"
|
||||||
size="10cqmin"
|
size={40}
|
||||||
component="a"
|
component="a"
|
||||||
|
radius={board.itemRadius}
|
||||||
href={user.link}
|
href={user.link}
|
||||||
>
|
>
|
||||||
<IconExternalLink className="mediaRequests-stats-users-user-link-icon" size="7.5cqmin" />
|
<IconExternalLink className="mediaRequests-stats-users-user-link-icon" size={25} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { MantineStyleProp } from "@mantine/core";
|
|
||||||
import { Avatar, Box, Flex, Group, Stack, Text, Title } from "@mantine/core";
|
import { Avatar, Box, Flex, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
import { IconDeviceAudioTape, IconDeviceTv, IconMovie, IconVideo } from "@tabler/icons-react";
|
import { IconDeviceAudioTape, IconDeviceTv, IconMovie, IconVideo } from "@tabler/icons-react";
|
||||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||||
@@ -36,13 +35,11 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
|
|||||||
header: "Name",
|
header: "Name",
|
||||||
mantineTableHeadCellProps: {
|
mantineTableHeadCellProps: {
|
||||||
style: {
|
style: {
|
||||||
fontSize: "7cqmin",
|
|
||||||
padding: "2cqmin",
|
|
||||||
width: "30%",
|
width: "30%",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cell: ({ row }) => (
|
Cell: ({ row }) => (
|
||||||
<Text size="7cqmin" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
<Text size="md" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{row.original.sessionName}
|
{row.original.sessionName}
|
||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
@@ -52,15 +49,13 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
|
|||||||
header: "User",
|
header: "User",
|
||||||
mantineTableHeadCellProps: {
|
mantineTableHeadCellProps: {
|
||||||
style: {
|
style: {
|
||||||
fontSize: "7cqmin",
|
|
||||||
padding: "2cqmin",
|
|
||||||
width: "25%",
|
width: "25%",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Cell: ({ row }) => (
|
Cell: ({ row }) => (
|
||||||
<Group gap={"2cqmin"}>
|
<Group gap={"sm"}>
|
||||||
<Avatar src={row.original.user.profilePictureUrl} size={"10cqmin"} />
|
<Avatar src={row.original.user.profilePictureUrl} size={30} />
|
||||||
<Text size="7cqmin">{row.original.user.username}</Text>
|
<Text size="md">{row.original.user.username}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -69,8 +64,6 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
|
|||||||
header: "Currently playing",
|
header: "Currently playing",
|
||||||
mantineTableHeadCellProps: {
|
mantineTableHeadCellProps: {
|
||||||
style: {
|
style: {
|
||||||
fontSize: "7cqmin",
|
|
||||||
padding: "2cqmin",
|
|
||||||
width: "45%",
|
width: "45%",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -78,9 +71,7 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
|
|||||||
if (row.original.currentlyPlaying) {
|
if (row.original.currentlyPlaying) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Text size="7cqmin" style={{ whiteSpace: "normal" }}>
|
<Text lineClamp={1}>{row.original.currentlyPlaying.name}</Text>
|
||||||
{row.original.currentlyPlaying.name}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,14 +120,6 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
|
|||||||
[currentStreams],
|
[currentStreams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseStyle: MantineStyleProp = {
|
|
||||||
"--total-width": "calc(100cqw / var(--total-width))",
|
|
||||||
"--ratio-width": "calc(100cqw / var(--total-width))",
|
|
||||||
"--space-size": "calc(var(--ratio-width) * 0.1)", //Standard gap and spacing value
|
|
||||||
"--text-fz": "calc(var(--ratio-width) * 0.45)", //General Font Size
|
|
||||||
"--icon-size": "calc(var(--ratio-width) * 2 / 3)", //Normal icon size
|
|
||||||
"--mrt-base-background-color": "transparent",
|
|
||||||
};
|
|
||||||
const { openModal } = useModalAction(itemInfoModal);
|
const { openModal } = useModalAction(itemInfoModal);
|
||||||
const table = useTranslatedMantineReactTable({
|
const table = useTranslatedMantineReactTable({
|
||||||
columns,
|
columns,
|
||||||
@@ -202,21 +185,21 @@ export default function MediaServerWidget({ integrationIds, isEditMode }: Widget
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} h="100%" display="flex" style={baseStyle}>
|
<Stack gap={0} h="100%" display="flex">
|
||||||
<MantineReactTable table={table} />
|
<MantineReactTable table={table} />
|
||||||
<Group
|
<Group
|
||||||
gap="1cqmin"
|
gap="xs"
|
||||||
h="var(--ratio-width)"
|
h={30}
|
||||||
px="var(--space-size)"
|
px="xs"
|
||||||
pr="5cqmin"
|
pr="md"
|
||||||
justify="flex-end"
|
justify="flex-end"
|
||||||
style={{
|
style={{
|
||||||
borderTop: "0.0625rem solid var(--border-color)",
|
borderTop: "1px solid var(--border-color)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{uniqueIntegrations.map((integration) => (
|
{uniqueIntegrations.map((integration) => (
|
||||||
<Group key={integration.integrationKind} gap="1cqmin" align="center">
|
<Group key={integration.integrationKind} gap="xs" align="center">
|
||||||
<Avatar className="media-server-icon" src={integration.integrationIcon} size="xs" />
|
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" />
|
||||||
<Text className="media-server-name" size="sm">
|
<Text className="media-server-name" size="sm">
|
||||||
{integration.integrationName}
|
{integration.integrationName}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Box, Flex, Group, Text, Tooltip } from "@mantine/core";
|
|||||||
import { IconUsersGroup } from "@tabler/icons-react";
|
import { IconUsersGroup } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { formatNumber } from "@homarr/common";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../../definition";
|
import type { WidgetComponentProps } from "../../definition";
|
||||||
@@ -29,15 +30,15 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
|
|||||||
h="100%"
|
h="100%"
|
||||||
w="100%"
|
w="100%"
|
||||||
direction="column"
|
direction="column"
|
||||||
p="7.5cqmin"
|
p="sm"
|
||||||
justify="center"
|
justify="center"
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
<Group gap="5cqmin" wrap="nowrap" align="center">
|
<Group gap="xs" wrap="nowrap" align="center">
|
||||||
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
|
<Tooltip label={data.online ? tStatus("online") : tStatus("offline")}>
|
||||||
<Box w="8cqmin" h="8cqmin" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
|
<Box w="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Text size="10cqmin" fw="bold">
|
<Text size="md" fw="bold">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -48,10 +49,10 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
|
|||||||
alt={`minecraft icon ${options.domain}`}
|
alt={`minecraft icon ${options.domain}`}
|
||||||
src={data.icon}
|
src={data.icon}
|
||||||
/>
|
/>
|
||||||
<Group gap="2cqmin" c="gray.6" align="center">
|
<Group gap={5} c="gray.6" align="center">
|
||||||
<IconUsersGroup style={{ width: "10cqmin", height: "10cqmin" }} />
|
<IconUsersGroup size="1rem" />
|
||||||
<Text size="10cqmin">
|
<Text size="md">
|
||||||
{data.players.online}/{data.players.max}
|
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -66,12 +66,12 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
const iconProps = {
|
const iconProps = {
|
||||||
size: "5cqmin",
|
size: 30,
|
||||||
stroke: 1.5,
|
stroke: 1.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
const controlIconProps = {
|
const controlIconProps = {
|
||||||
size: "5cqmin",
|
size: 20,
|
||||||
stroke: 1.5,
|
stroke: 1.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IconClock } from "@tabler/icons-react";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import classes from "./component.module.scss";
|
import classes from "./component.module.scss";
|
||||||
@@ -22,29 +23,30 @@ export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) {
|
|||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
|
||||||
const languageDir = options.enableRtl ? "RTL" : "LTR";
|
const languageDir = options.enableRtl ? "RTL" : "LTR";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="scroll-area-w100" w="100%" p="4cqmin">
|
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
|
||||||
<Stack w={"100%"} gap="4cqmin">
|
<Stack w={"100%"} gap="sm">
|
||||||
{feedEntries.map((feedEntry) => (
|
{feedEntries.map((feedEntry) => (
|
||||||
<Card
|
<Card
|
||||||
key={feedEntry.id}
|
key={feedEntry.id}
|
||||||
withBorder
|
withBorder
|
||||||
component={"a"}
|
component={"a"}
|
||||||
href={feedEntry.link}
|
href={feedEntry.link}
|
||||||
radius="2.5cqmin"
|
radius={board.itemRadius}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
w="100%"
|
w="100%"
|
||||||
p="2.5cqmin"
|
p="sm"
|
||||||
>
|
>
|
||||||
{feedEntry.enclosure && (
|
{feedEntry.enclosure && (
|
||||||
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
|
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Flex gap="2.5cqmin" direction="column" w="100%">
|
<Flex gap="sm" direction="column" w="100%">
|
||||||
<Text dir={languageDir} fz="4cqmin" lh="5cqmin" lineClamp={2}>
|
<Text dir={languageDir} fz="sm" lh="sm" lineClamp={2}>
|
||||||
{feedEntry.title}
|
{feedEntry.title}
|
||||||
</Text>
|
</Text>
|
||||||
{feedEntry.description && (
|
{feedEntry.description && (
|
||||||
@@ -52,7 +54,7 @@ export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) {
|
|||||||
className={feedEntry.description}
|
className={feedEntry.description}
|
||||||
dir={languageDir}
|
dir={languageDir}
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
size="3.5cqmin"
|
size="sm"
|
||||||
lineClamp={options.textLinesClamp as number}
|
lineClamp={options.textLinesClamp as number}
|
||||||
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
|
dangerouslySetInnerHTML={{ __html: feedEntry.description }}
|
||||||
/>
|
/>
|
||||||
@@ -68,9 +70,9 @@ export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const InfoDisplay = ({ date }: { date: string }) => (
|
const InfoDisplay = ({ date }: { date: string }) => (
|
||||||
<Group gap="2.5cqmin">
|
<Group gap={5} align={"center"}>
|
||||||
<IconClock size="2.5cqmin" />
|
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
|
||||||
<Text size="2.5cqmin" c="dimmed" pt="1cqmin">
|
<Text size="sm" c="dimmed">
|
||||||
{date}
|
{date}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
|
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
|
||||||
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
|
import { IconArrowDownRight, IconArrowUpRight, IconMapPin, IconWind } from "@tabler/icons-react";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@@ -28,8 +28,8 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
|
|||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
align="center"
|
align="center"
|
||||||
|
gap="sm"
|
||||||
justify="center"
|
justify="center"
|
||||||
gap="0"
|
|
||||||
w="100%"
|
w="100%"
|
||||||
h="100%"
|
h="100%"
|
||||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||||
@@ -49,20 +49,21 @@ interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options">
|
|||||||
|
|
||||||
const DailyWeather = ({ options, weather }: WeatherProps) => {
|
const DailyWeather = ({ options, weather }: WeatherProps) => {
|
||||||
const t = useScopedI18n("widget.weather");
|
const t = useScopedI18n("widget.weather");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group className="weather-day-group" gap="1cqmin">
|
<Group className="weather-day-group" gap="sm">
|
||||||
<HoverCard>
|
<HoverCard>
|
||||||
<HoverCard.Target>
|
<HoverCard.Target>
|
||||||
<Box>
|
<Box>
|
||||||
<WeatherIcon size="20cqmin" code={weather.current.weathercode} />
|
<WeatherIcon size={30} code={weather.current.weathercode} />
|
||||||
</Box>
|
</Box>
|
||||||
</HoverCard.Target>
|
</HoverCard.Target>
|
||||||
<HoverCard.Dropdown>
|
<HoverCard.Dropdown>
|
||||||
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
</HoverCard.Dropdown>
|
</HoverCard.Dropdown>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<Text fz="17.5cqmin">
|
<Text fz={30}>
|
||||||
{getPreferredUnit(
|
{getPreferredUnit(
|
||||||
weather.current.temperature,
|
weather.current.temperature,
|
||||||
options.isFormatFahrenheit,
|
options.isFormatFahrenheit,
|
||||||
@@ -70,31 +71,41 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Space h="1cqmin" />
|
<Stack gap="xs" align="center">
|
||||||
{options.showCurrentWindSpeed && (
|
{options.showCurrentWindSpeed && (
|
||||||
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="1cqmin">
|
<Group className="weather-current-wind-speed-group" wrap="nowrap" gap="xs">
|
||||||
<IconWind size="12.5cqmin" />
|
<IconWind size={16} />
|
||||||
<Text fz="10cqmin">{t("currentWindSpeed", { currentWindSpeed: weather.current.windspeed })}</Text>
|
<Text fz={16}>{t("currentWindSpeed", { currentWindSpeed: weather.current.windspeed })}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="sm">
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<IconArrowUpRight size={16} />
|
||||||
|
<Text fz={16}>
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.daily[0]?.maxTemp,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
options.disableTemperatureDecimals,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<IconArrowDownRight size={16} />
|
||||||
|
<Text fz={16}>
|
||||||
|
{getPreferredUnit(
|
||||||
|
weather.daily[0]?.minTemp,
|
||||||
|
options.isFormatFahrenheit,
|
||||||
|
options.disableTemperatureDecimals,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
</Stack>
|
||||||
<Space h="1cqmin" />
|
|
||||||
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
|
|
||||||
<IconArrowUpRight size="12.5cqmin" />
|
|
||||||
<Text fz="10cqmin">
|
|
||||||
{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
|
||||||
</Text>
|
|
||||||
<Space w="2.5cqmin" />
|
|
||||||
<IconArrowDownRight size="12.5cqmin" />
|
|
||||||
<Text fz="10cqmin">
|
|
||||||
{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
{options.showCity && (
|
{options.showCity && (
|
||||||
<>
|
<>
|
||||||
<Space h="5cqmin" />
|
<Group className="weather-city-group" wrap="nowrap" gap="xs">
|
||||||
<Group className="weather-city-group" wrap="nowrap" gap="1cqmin">
|
<IconMapPin size={16} />
|
||||||
<IconMapPin size="12.5cqmin" />
|
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
|
||||||
<Text size="12.5cqmin" style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{options.location.name}
|
{options.location.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -107,35 +118,35 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
|
|||||||
const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="5cqmin">
|
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
|
||||||
{options.showCity && (
|
{options.showCity && (
|
||||||
<>
|
<Group gap="xs" wrap="nowrap">
|
||||||
<IconMapPin size="20cqmin" />
|
<IconMapPin size={16} />
|
||||||
<Text size="15cqmin" style={{ whiteSpace: "nowrap" }}>
|
<Text fz={16} style={{ whiteSpace: "nowrap" }}>
|
||||||
{options.location.name}
|
{options.location.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Space w="20cqmin" />
|
</Group>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<HoverCard>
|
<Group gap="xs" wrap="nowrap">
|
||||||
<HoverCard.Target>
|
<HoverCard>
|
||||||
<Box>
|
<HoverCard.Target>
|
||||||
<WeatherIcon size="20cqmin" code={weather.current.weathercode} />
|
<Box>
|
||||||
</Box>
|
<WeatherIcon size={16} code={weather.current.weathercode} />
|
||||||
</HoverCard.Target>
|
</Box>
|
||||||
<HoverCard.Dropdown>
|
</HoverCard.Target>
|
||||||
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
<HoverCard.Dropdown>
|
||||||
</HoverCard.Dropdown>
|
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
|
||||||
</HoverCard>
|
</HoverCard.Dropdown>
|
||||||
<Text fz="20cqmin">
|
</HoverCard>
|
||||||
{getPreferredUnit(
|
<Text fz={16}>
|
||||||
weather.current.temperature,
|
{getPreferredUnit(
|
||||||
options.isFormatFahrenheit,
|
weather.current.temperature,
|
||||||
options.disableTemperatureDecimals,
|
options.isFormatFahrenheit,
|
||||||
)}
|
options.disableTemperatureDecimals,
|
||||||
</Text>
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Space h="2.5cqmin" />
|
|
||||||
<Forecast weather={weather} options={options} />
|
<Forecast weather={weather} options={options} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -144,7 +155,7 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
|
|||||||
function Forecast({ weather, options }: WeatherProps) {
|
function Forecast({ weather, options }: WeatherProps) {
|
||||||
const dateFormat = options.dateFormat;
|
const dateFormat = options.dateFormat;
|
||||||
return (
|
return (
|
||||||
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="2.5cqmin">
|
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="sm">
|
||||||
{weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
|
{weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
|
||||||
<HoverCard key={dayWeather.time} withArrow shadow="md">
|
<HoverCard key={dayWeather.time} withArrow shadow="md">
|
||||||
<HoverCard.Target>
|
<HoverCard.Target>
|
||||||
@@ -157,9 +168,9 @@ function Forecast({ weather, options }: WeatherProps) {
|
|||||||
gap="0"
|
gap="0"
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
<Text fz="10cqmin">{dayjs(dayWeather.time).format("dd")}</Text>
|
<Text fz="xl">{dayjs(dayWeather.time).format("dd")}</Text>
|
||||||
<WeatherIcon size="15cqmin" code={dayWeather.weatherCode} />
|
<WeatherIcon size={16} code={dayWeather.weatherCode} />
|
||||||
<Text fz="10cqmin">
|
<Text fz={16}>
|
||||||
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user