fix(widgets): replace system-resources chart tooltip with Mantine (#6729)
* fix(widgets): replace system-resources chart tooltip with Mantine Tooltip.Floating
This commit is contained in:
1 parent
5f572f1b92
commit
c18fa8cf77
7 files changed
+238
-170
No files matched your search
@@ -4851,7 +4851,8 @@
|
||||
"memory": "MEM",
|
||||
"network": "NET",
|
||||
"up": "UP",
|
||||
"down": "DOWN"
|
||||
"down": "DOWN",
|
||||
"memoryTooltip": "{used} / {available} ({percent}%)"
|
||||
}
|
||||
},
|
||||
"systemDisks": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconNetwork } from "@tabler/icons-react";
|
||||
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
@@ -21,9 +21,43 @@ export const CombinedNetworkTrafficChart = ({
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
|
||||
const chartData = usageOverTime.map((usage, index) => ({
|
||||
index,
|
||||
up: usage.up,
|
||||
down: usage.down,
|
||||
}));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
|
||||
const tooltipLabel = (index: number) => {
|
||||
const point = usageOverTime[index];
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
bg="orange.5"
|
||||
w={8}
|
||||
h={8}
|
||||
style={{ borderRadius: 99, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs">
|
||||
{t("up")}: {formatByteRate(Math.round(point?.up ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Box
|
||||
bg="yellow.5"
|
||||
w={8}
|
||||
h={8}
|
||||
style={{ borderRadius: 99, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs">
|
||||
{t("down")}: {formatByteRate(Math.round(point?.down ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CommonChart
|
||||
data={chartData}
|
||||
@@ -38,28 +72,7 @@ export const CombinedNetworkTrafficChart = ({
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Stack gap={0}>
|
||||
{payload.map((payloadData) => (
|
||||
<Group key={payloadData.key} gap={4}>
|
||||
<Box bg={payloadData.color} w={10} h={10} style={{ borderRadius: 99 }}></Box>
|
||||
<Text c="dimmed" size="xs">
|
||||
{payloadData.value === undefined ? (
|
||||
<>N/A</>
|
||||
) : (
|
||||
formatByteRate(Math.round(Number(payloadData.value)))
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
tooltipLabel={tooltipLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,24 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import type {
|
||||
CSSProperties,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { useState } from "react";
|
||||
import type { AreaChartSeries } from "@mantine/charts";
|
||||
import { AreaChart, LineChart } from "@mantine/charts";
|
||||
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
|
||||
import {
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
|
||||
import type { TooltipProps, YAxisProps } from "recharts";
|
||||
import type { YAxisProps } from "recharts";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { zoomCompensatedSize } from "@homarr/ui";
|
||||
@@ -19,7 +33,7 @@ export const CommonChart = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
labelDisplayMode,
|
||||
tooltipProps,
|
||||
tooltipLabel,
|
||||
yAxisProps,
|
||||
lastValue,
|
||||
chartType = "line",
|
||||
@@ -31,7 +45,10 @@ export const CommonChart = ({
|
||||
title: ReactNode;
|
||||
icon: TablerIcon;
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
tooltipProps?: TooltipProps<number, any>;
|
||||
// What to show on hover. Defaults to lastValue when omitted. Pass a function to reflect
|
||||
// the datum under the cursor as it moves across multi-point data (e.g. memory's
|
||||
// used/available at that point), instead of always showing the latest value.
|
||||
tooltipLabel?: ReactNode | ((index: number) => ReactNode);
|
||||
yAxisProps?: Omit<YAxisProps, "ref">;
|
||||
lastValue?: string;
|
||||
chartType?: "line" | "area";
|
||||
@@ -45,63 +62,80 @@ export const CommonChart = ({
|
||||
const ref = useMergedRef(elementSizeRef, hoverRef);
|
||||
|
||||
const opacity = board.opacity / 100;
|
||||
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
|
||||
let backgroundColor =
|
||||
colorScheme === "dark"
|
||||
? `rgba(57, 57, 57, ${opacity})`
|
||||
: `rgba(246, 247, 248, ${opacity})`;
|
||||
const cardStyle: CSSProperties = { overflow: "hidden" };
|
||||
const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] };
|
||||
const chartRootStyle: CSSProperties = {
|
||||
padding: 5,
|
||||
borderRadius: theme.radius[board.itemRadius],
|
||||
};
|
||||
if (advanced) {
|
||||
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
backgroundColor =
|
||||
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
|
||||
cardStyle.border =
|
||||
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
|
||||
chartRootStyle.backgroundColor = backgroundColor;
|
||||
}
|
||||
|
||||
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
|
||||
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
|
||||
const showIcon =
|
||||
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
|
||||
const showText =
|
||||
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
|
||||
|
||||
// Track which data point the cursor is over so the tooltip can reflect that point
|
||||
// instead of always showing the latest value while scrubbing across the chart.
|
||||
const [hoveredIndex, setHoveredIndex] = useState(data.length - 1);
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (data.length <= 1) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
const ratio = (event.clientX - rect.left) / rect.width;
|
||||
const index = Math.round(ratio * (data.length - 1));
|
||||
setHoveredIndex(Math.min(data.length - 1, Math.max(0, index)));
|
||||
};
|
||||
|
||||
// Clamp on read rather than syncing via an effect - handles data growing/shrinking
|
||||
// between renders (e.g. history resetting on integration change) without ever
|
||||
// handing a stale or out-of-range index to tooltipLabel.
|
||||
const clampedHoveredIndex = Math.max(
|
||||
0,
|
||||
Math.min(hoveredIndex, data.length - 1),
|
||||
);
|
||||
const resolvedTooltipLabel =
|
||||
typeof tooltipLabel === "function"
|
||||
? tooltipLabel(clampedHoveredIndex)
|
||||
: (tooltipLabel ?? lastValue);
|
||||
|
||||
return (
|
||||
<Card ref={ref} h={"100%"} pos={"relative"} style={cardStyle} p={0} bg={backgroundColor} radius={board.itemRadius}>
|
||||
{data.length > 1 && height > 40 && !hovered && (
|
||||
<Group
|
||||
pos={"absolute"}
|
||||
top={0}
|
||||
left={0}
|
||||
p={8}
|
||||
pt={6}
|
||||
gap={5}
|
||||
wrap={"nowrap"}
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
align="center"
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{lastValue && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
|
||||
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
|
||||
<Text size="xs" fw={600}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
<Tooltip.Floating
|
||||
label={resolvedTooltipLabel}
|
||||
disabled={data.length <= 1 || !resolvedTooltipLabel}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
h={"100%"}
|
||||
pos={"relative"}
|
||||
style={cardStyle}
|
||||
p={0}
|
||||
bg={backgroundColor}
|
||||
radius={board.itemRadius}
|
||||
onPointerMove={handlePointerMove}
|
||||
>
|
||||
{data.length > 1 && height > 40 && !hovered && (
|
||||
<Group
|
||||
pos={"absolute"}
|
||||
top={0}
|
||||
left={0}
|
||||
p={8}
|
||||
pt={6}
|
||||
gap={5}
|
||||
wrap={"nowrap"}
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
align="center"
|
||||
>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
@@ -110,43 +144,85 @@ export const CommonChart = ({
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
|
||||
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{advanced && lastValue && (
|
||||
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||
{lastValue && (
|
||||
<Text
|
||||
c={"dimmed"}
|
||||
size={height > 100 ? "md" : "xs"}
|
||||
lineClamp={1}
|
||||
>
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ChartComponent
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
h={"100%"}
|
||||
series={series}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withXAxis={false}
|
||||
withYAxis={false}
|
||||
withDots={false}
|
||||
bg={backgroundColor}
|
||||
styles={{ root: chartRootStyle }}
|
||||
tooltipAnimationDuration={200}
|
||||
tooltipProps={{
|
||||
// Render via portal so the tooltip isn't clipped by the card's overflow:hidden
|
||||
// (needed to stop the chart line itself bleeding past its rounded corners).
|
||||
portal: typeof document !== "undefined" ? document.body : undefined,
|
||||
...tooltipProps,
|
||||
}}
|
||||
withTooltip={height >= 64}
|
||||
yAxisProps={yAxisProps}
|
||||
fillOpacity={chartType === "area" ? 0.3 : undefined}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Group>
|
||||
)}
|
||||
{advanced &&
|
||||
data.length > 1 &&
|
||||
height > 0 &&
|
||||
height <= 40 &&
|
||||
lastValue &&
|
||||
!hovered && (
|
||||
<Center
|
||||
pos="absolute"
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{ zIndex: 2, pointerEvents: "none" }}
|
||||
>
|
||||
<Text size="xs" fw={600}>
|
||||
{lastValue}
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
{data.length <= 1 ? (
|
||||
<Center pos="absolute" w="100%" h="100%">
|
||||
<Stack px={"xs"} align={"center"} gap={4}>
|
||||
{showIcon && (
|
||||
<Icon
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={zoomCompensatedSize(height > 100 ? 20 : 14)}
|
||||
stroke={1.5}
|
||||
/>
|
||||
)}
|
||||
{showText && (
|
||||
<Text
|
||||
c={"dimmed"}
|
||||
size={height > 100 ? "md" : "xs"}
|
||||
fw={"bold"}
|
||||
ta="center"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{advanced && lastValue && (
|
||||
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
|
||||
{lastValue}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<ChartComponent
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
h={"100%"}
|
||||
series={series}
|
||||
curveType="monotone"
|
||||
tickLine="none"
|
||||
gridAxis="none"
|
||||
withXAxis={false}
|
||||
withYAxis={false}
|
||||
withDots={false}
|
||||
bg={backgroundColor}
|
||||
styles={{ root: chartRootStyle }}
|
||||
withTooltip={false}
|
||||
yAxisProps={yAxisProps}
|
||||
fillOpacity={chartType === "area" ? 0.3 : undefined}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Tooltip.Floating>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconCpu } from "@tabler/icons-react";
|
||||
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
@@ -27,25 +26,16 @@ export const SystemResourceCPUChart = ({
|
||||
title={invariantTechnicalLabels.cpu}
|
||||
icon={IconCpu}
|
||||
lastValue={
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
|
||||
cpuUsageOverTime.length > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
`${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%`
|
||||
: undefined
|
||||
}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
yAxisProps={{ domain: [0, 100] }}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{value.toFixed(0)}%
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
tooltipLabel={(index) => `${Math.round(cpuUsageOverTime[index] ?? 0)}%`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconDeviceDesktop } from "@tabler/icons-react";
|
||||
|
||||
import { invariantTechnicalLabels } from "@homarr/definitions";
|
||||
@@ -27,25 +26,16 @@ export const SystemResourceGPUChart = ({
|
||||
title={invariantTechnicalLabels.gpu}
|
||||
icon={IconDeviceDesktop}
|
||||
lastValue={
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
gpuUsageOverTime.length > 0 ? `${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%` : undefined
|
||||
gpuUsageOverTime.length > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
`${Math.round(gpuUsageOverTime[gpuUsageOverTime.length - 1]!)}%`
|
||||
: undefined
|
||||
}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
yAxisProps={{ domain: [0, 100] }}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{value.toFixed(0)}%
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
tooltipLabel={(index) => `${Math.round(gpuUsageOverTime[index] ?? 0)}%`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconBrain } from "@tabler/icons-react";
|
||||
|
||||
import { formatBytesPair } from "@homarr/common";
|
||||
@@ -20,15 +19,33 @@ export const SystemResourceMemoryChart = ({
|
||||
labelDisplayMode: LabelDisplayModeOption;
|
||||
advanced?: boolean;
|
||||
}) => {
|
||||
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
|
||||
const chartData = memoryUsageOverTime.map((usage, index) => ({
|
||||
index,
|
||||
usage,
|
||||
}));
|
||||
const t = useI18n("widget.systemResources.card");
|
||||
|
||||
const percentageUsed =
|
||||
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
|
||||
memoryUsageOverTime[memoryUsageOverTime.length - 1]! /
|
||||
totalCapacityInBytes
|
||||
: undefined;
|
||||
|
||||
const tooltipLabel = (index: number) => {
|
||||
const used = memoryUsageOverTime[index] ?? 0;
|
||||
const memory = formatBytesPair(Math.round(used), totalCapacityInBytes);
|
||||
const percent =
|
||||
totalCapacityInBytes > 0
|
||||
? Math.round((used / totalCapacityInBytes) * 100)
|
||||
: 0;
|
||||
return t("memoryTooltip", {
|
||||
used: memory.used,
|
||||
available: memory.available,
|
||||
percent,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CommonChart
|
||||
data={chartData}
|
||||
@@ -39,22 +56,13 @@ export const SystemResourceMemoryChart = ({
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
|
||||
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
|
||||
lastValue={
|
||||
percentageUsed !== undefined
|
||||
? `${Math.round(percentageUsed * 100)}%`
|
||||
: undefined
|
||||
}
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
const memory = formatBytesPair(Math.round(value), totalCapacityInBytes);
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{memory.used} / {memory.available} (
|
||||
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
tooltipLabel={tooltipLabel}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Paper, Text } from "@mantine/core";
|
||||
import { IconArrowDown, IconArrowUp } from "@tabler/icons-react";
|
||||
|
||||
import { formatByteRate } from "@homarr/common";
|
||||
@@ -39,18 +38,9 @@ export const NetworkTrafficChart = ({
|
||||
chartType={hasShadow ? "area" : "line"}
|
||||
labelDisplayMode={labelDisplayMode}
|
||||
advanced={advanced}
|
||||
tooltipProps={{
|
||||
content: ({ payload }) => {
|
||||
const value = payload[0] ? Number(payload[0].value) : 0;
|
||||
return (
|
||||
<Paper px={3} py={2} shadow="md">
|
||||
<Text c="dimmed" size="xs">
|
||||
{formatByteRate(Math.round(value))}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
},
|
||||
}}
|
||||
tooltipLabel={(index) =>
|
||||
formatByteRate(Math.round(usageOverTime[index] ?? 0))
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user