feat: refine widget interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent a8a7545f40
commit 842e86012d
107 files changed
+3144 -1233

No files matched your search

@@ -4,6 +4,34 @@
height: 100%;
}
.homarr-anchor-actions {
opacity: 0;
transform: translateY(-2px);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.homarr-anchor-note:hover .homarr-anchor-actions,
.homarr-anchor-note:focus-within .homarr-anchor-actions,
.homarr-anchor-actions[data-visible="true"] {
opacity: 1;
transform: none;
}
@media (any-pointer: coarse) {
.homarr-anchor-actions {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.homarr-anchor-actions {
transition: none;
}
}
.homarr-anchor-quill .ql-toolbar.ql-snow,
.homarr-anchor-quill .ql-container.ql-snow {
border: 0;
+26 -17
View File
@@ -3,7 +3,8 @@
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
@@ -295,7 +296,7 @@ const AnchorNoteWidgetContent = ({
if (!note) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
<Stack className="homarr-anchor-note" h="100%" gap="xs" p={height < 120 ? "xs" : "sm"}>
<Group justify="space-between" align="flex-start">
<Stack gap={2} style={{ flex: 1 }}>
{isEditing ? (
@@ -335,22 +336,30 @@ const AnchorNoteWidgetContent = ({
</Text>
)}
</Stack>
<Group gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
{(isEditing || canEdit) && (
<Group className="homarr-anchor-actions" data-visible={isEditing || isAdvanced || undefined} gap="xs">
{isEditing ? (
<>
<Button size="xs" onClick={handleSave} loading={isUpdating} disabled={!hasChanges || !canEdit}>
{t("save")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : isAdvanced ? (
<Button size="xs" variant="light" onClick={handleEdit} disabled={isUpdating}>
{t("edit")}
</Button>
<Button size="xs" variant="subtle" onClick={handleCancel} disabled={isUpdating}>
{t("cancel")}
</Button>
</>
) : (
<Button size="xs" variant="light" onClick={handleEdit} disabled={!canEdit || isUpdating}>
{t("edit")}
</Button>
)}
</Group>
) : (
<Tooltip label={t("edit")}>
<ActionIcon aria-label={t("edit")} size="md" variant="light" onClick={handleEdit} disabled={isUpdating}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
)}
</Group>
{isEditing || isAdvanced ? (
<div
+5 -19
View File
@@ -19,7 +19,7 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
@@ -51,7 +51,6 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isAdvanced = displayMode === "advanced";
const isColumnLayout = options.layout.startsWith("column");
return (
@@ -69,7 +68,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode || isAdvanced}
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
@@ -84,7 +83,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{(options.showTitle || isAdvanced) && (
{options.showTitle && (
<Text
className="app-title"
fw={700}
@@ -94,13 +93,13 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
{app.name}
</Text>
)}
{(options.descriptionDisplayMode === "normal" || isAdvanced) && (
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={isAdvanced ? 10 : Math.max(1, Math.floor((height - 48) / 18))}
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -110,11 +109,6 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
))}
</Text>
)}
{isAdvanced && app.href && (
<Text size="xs" c="dimmed" lineClamp={1}>
{getDisplayHost(app.href)}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
@@ -160,11 +154,3 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
) : (
children
);
const getDisplayHost = (href: string) => {
try {
return new URL(href).host;
} catch {
return href;
}
};
@@ -53,9 +53,16 @@ const ArchiveTeamWarriorWidgetContent = ({
const status = data.status;
const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
const statusKey = getStatusKey(status.status);
const layout = getArchiveCompactLayout(width, height, isAdvanced);
const countMetrics = [
{ label: t("metric.running"), value: status.counts.running },
...(layout.showSecondaryCounts ? [{ label: t("metric.completed"), value: status.counts.completed }] : []),
{ label: t("metric.failed"), value: status.counts.failed },
...(layout.showSecondaryCounts ? [{ label: t("metric.canceled"), value: status.counts.canceled }] : []),
];
return (
<Stack p="xs" gap="xs" h="100%" style={{ overflow: "hidden" }}>
<Stack p="xs" gap={layout.gap} h="100%" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size="sm" radius="md" src={getIconUrl("archiveTeamWarrior")} />
@@ -69,7 +76,7 @@ const ArchiveTeamWarriorWidgetContent = ({
</Badge>
</Group>
{options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && (
{options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && (
<Card withBorder p="xs">
<Text size="xs" lineClamp={3}>
{status.broadcastMessage}
@@ -77,17 +84,16 @@ const ArchiveTeamWarriorWidgetContent = ({
</Card>
)}
<SimpleGrid cols={width < 180 || height < 120 ? 4 : 2} spacing="xs">
<Metric label={t("metric.running")} value={status.counts.running} />
<Metric label={t("metric.completed")} value={status.counts.completed} />
<Metric label={t("metric.failed")} value={status.counts.failed} />
<Metric label={t("metric.canceled")} value={status.counts.canceled} />
<SimpleGrid cols={layout.metricColumns} spacing={layout.gap}>
{countMetrics.map((metric) => (
<Metric key={metric.label} {...metric} dense={layout.dense} />
))}
</SimpleGrid>
{status.bandwidth && (
<SimpleGrid cols={2} spacing="xs">
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} />
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} />
{status.bandwidth && layout.showBandwidth && (
<SimpleGrid cols={2} spacing={layout.gap}>
<Metric label={t("metric.download")} value={formatBandwidth(status.bandwidth.receiving)} dense />
<Metric label={t("metric.upload")} value={formatBandwidth(status.bandwidth.sending)} dense />
</SimpleGrid>
)}
{isAdvanced && (
@@ -130,17 +136,32 @@ const ArchiveTeamWarriorWidgetContent = ({
);
};
const Metric = ({ label, value }: { label: string; value: number | string }) => (
const Metric = ({ label, value, dense = false }: { label: string; value: number | string; dense?: boolean }) => (
<Stack gap={0} miw={0}>
<Text size="md" fw={700} lineClamp={1}>
<Text size={dense ? "sm" : "md"} fw={700} lineClamp={1} title={String(value)}>
{value}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
<Text size="xs" c="dimmed" lineClamp={1} title={label}>
{label}
</Text>
</Stack>
);
export const getArchiveCompactLayout = (width: number, height: number, isAdvanced = false) => {
const dense = !isAdvanced && (width < 240 || height < 160);
const showSecondaryCounts = isAdvanced || (width >= 200 && height >= 120);
const metricCount = showSecondaryCounts ? 4 : 2;
return {
dense,
gap: dense ? 4 : "xs",
metricColumns: height < 160 ? metricCount : Math.min(metricCount, width >= 260 ? 4 : 2),
showBandwidth: isAdvanced || height >= 190,
showBroadcast: isAdvanced || height >= 240,
showSecondaryCounts,
} as const;
};
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
@@ -0,0 +1,23 @@
import { describe, expect, test } from "vitest";
import { getArchiveCompactLayout } from "./component";
describe("archive team warrior compact layout", () => {
test("keeps only primary counters in a minimum-size widget", () => {
expect(getArchiveCompactLayout(180, 100)).toMatchObject({
metricColumns: 2,
showBandwidth: false,
showBroadcast: false,
showSecondaryCounts: false,
});
});
test("adds secondary information as height becomes available", () => {
expect(getArchiveCompactLayout(320, 260)).toMatchObject({
metricColumns: 4,
showBandwidth: true,
showBroadcast: true,
showSecondaryCounts: true,
});
});
});
@@ -8,7 +8,14 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
import {
AUDIO_STATS_TRANSLATION_SCOPE,
getGridCols,
getIconSize,
prioritizeVisibleStats,
getVisibleStatLimit,
getVisibleStats,
} from "./shared";
const rootClassByCompact = {
true: classes.rootCompact,
@@ -30,14 +37,17 @@ interface AudioStatsContentProps {
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
width: number;
height: number;
}
export function AudioStatsContent({ backend, stats, options, width }: AudioStatsContentProps) {
export function AudioStatsContent({ backend, stats, options, width, height }: AudioStatsContentProps) {
const t = useScopedI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const visibleStats = getVisibleStats(backend, options, stats);
const compactKey = String(options.compactMode ?? false) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false);
const iconSize = getIconSize(width, options.compactMode ?? false);
const compact = options.compactMode ?? false;
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
const compactKey = String(compact) as keyof typeof rootClassByCompact;
const gridCols = getGridCols(width, visibleStats.length, compact);
const iconSize = getIconSize(width, compact);
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
describe("getGridCols", () => {
test("caps columns to visible stat count", () => {
@@ -37,6 +37,17 @@ describe("getIconSize", () => {
});
});
describe("getVisibleStatLimit", () => {
test("keeps compact metrics legible when height is constrained", () => {
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
});
test("preserves every configured metric outside compact mode", () => {
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
});
});
describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = {
artistCount: 100,
@@ -91,3 +102,21 @@ describe("getVisibleStats", () => {
expect(typeof result[0]?.value).toBe("string");
});
});
describe("prioritizeVisibleStats", () => {
test("puts an active session first only on compact surfaces", () => {
const stats = getVisibleStats(
"audiobookshelf",
{ showLibraryCount: true, showActiveSessions: true },
{
libraryCount: 3,
totalAudiobooks: 150,
totalPodcasts: 20,
totalListeningTimeSeconds: 86400,
activeSessions: 2,
},
);
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
});
});
@@ -40,6 +40,7 @@ export default function AudioStatsWidget({
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
width={width}
height={height}
/>
);
if (displayMode === "compact") return summary;
@@ -155,6 +155,23 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea
return Math.min(maxCols, Math.max(visibleCount, 1));
};
export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => {
if (!compact) return visibleCount;
const columns = getGridCols(width, visibleCount, true);
const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44));
return Math.min(visibleCount, columns * rows);
};
export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => {
if (!compact) return stats;
return stats.toSorted(
(left, right) =>
Number(right.statKey === "activeSessions" && Number(right.value) > 0) -
Number(left.statKey === "activeSessions" && Number(left.value) > 0),
);
};
const fallbackIconSize = { true: 14, false: 16 } as const;
export const getIconSize = (width: number, compact: boolean): number => {
+3 -1
View File
@@ -89,7 +89,9 @@ export default function BazarrWidget({
<div key={statKey} className={`${classes.statTile} ${isWarning ? classes.statTileWarning : ""}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={`${classes.statValue} ${isWarning ? classes.statValueWarning : ""}`}>{value}</span>
<span className={classes.statLabel}>{t(statKey)}</span>
<span className={classes.statLabel} title={t(statKey)}>
{t(statKey)}
</span>
</div>
);
})}
@@ -1,7 +1,20 @@
"use client";
import { useMemo } from "react";
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Badge,
Box,
Center,
Divider,
Group,
Loader,
ScrollArea,
Stack,
Text,
ThemeIcon,
Tooltip,
VisuallyHidden,
} from "@mantine/core";
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -71,6 +84,7 @@ export default function BeszelAlertsWidget({
const okAlerts = alerts.filter((a) => !a.triggered);
const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
if (alertsError) throw alertsError;
@@ -104,7 +118,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} tt="uppercase" c="red">
<Text size="xs" fw={600} c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
</Group>
@@ -116,6 +130,7 @@ export default function BeszelAlertsWidget({
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
showDescription={showAlertDescriptions}
/>
))}
</Stack>
@@ -127,7 +142,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
<Text size="xs" fw={600} c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
</Group>
@@ -139,6 +154,7 @@ export default function BeszelAlertsWidget({
min={alert.min}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
showDescription={showAlertDescriptions}
/>
))}
</Stack>
@@ -150,7 +166,7 @@ export default function BeszelAlertsWidget({
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
<Text size="xs" fw={600} c="dimmed">
{t("history")}
</Text>
</Group>
@@ -203,6 +219,7 @@ interface AlertRowProps {
min: number;
systemName: string;
triggered: boolean;
showDescription: boolean;
}
const unitSuffixMap: Record<string, string> = {
@@ -226,38 +243,43 @@ function formatAlertDescription(name: string, value: number, min: number): strin
return `exceeds ${value}${suffix} over ${min} min`;
}
function AlertRow({ name, value, min, systemName, triggered }: AlertRowProps) {
function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) {
const Icon = alertIconMap[name] ?? Server;
const description = formatAlertDescription(name, value, min);
return (
<Group
wrap="nowrap"
gap="xs"
py={4}
pr={8}
pl={12}
style={{
borderRadius: "var(--mantine-radius-sm)",
borderLeft: `3px solid var(--mantine-color-${triggered ? "red" : "green"}-6)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{formatAlertDescription(name, value, min)}
</Text>
</Stack>
</Group>
<Tooltip label={description} disabled={showDescription} withArrow>
<Group
wrap="nowrap"
gap="xs"
py={showDescription ? 4 : 2}
px={8}
style={{
borderRadius: "var(--mantine-radius-sm)",
border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`,
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{name}
</Text>
</Group>
{showDescription && (
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
)}
{!showDescription && <VisuallyHidden>{description}</VisuallyHidden>}
</Stack>
</Group>
</Tooltip>
);
}
@@ -1,7 +1,20 @@
.clickableCard {
transition: background-color 150ms;
cursor: pointer;
}
.clickableCard:hover {
.clickableCard:hover,
.clickableCard:focus-visible {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.clickableCard:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.clickableCard {
transition: none;
}
}
@@ -336,6 +336,9 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
return (
<Card
component="button"
type="button"
disabled={!onClick}
padding={size.cardPadding}
radius={itemRadius}
bg="transparent"
@@ -347,7 +350,10 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick
display: "flex",
flexDirection: "column" as const,
border: "0.0625rem solid var(--border-color)",
cursor: onClick ? "pointer" : undefined,
color: "inherit",
font: "inherit",
textAlign: "left",
width: "100%",
}}
>
<Group gap="xs" mb={2}>
@@ -8,7 +8,21 @@
pointer-events: none;
}
.beszelStatsContainer:hover .beszelStatsSystemToggle {
.beszelStatsContainer:hover .beszelStatsSystemToggle,
.beszelStatsContainer:focus-within .beszelStatsSystemToggle {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.beszelStatsSystemToggle {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.beszelStatsSystemToggle {
transition: none;
}
}
@@ -1,10 +1,9 @@
"use client";
import type { CSSProperties } from "react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
dayjs.extend(localizedFormat);
import { formatLocalizedDate } from "../../common/locale";
const styles: Record<string, CSSProperties> = {
wrapper: {
@@ -54,6 +53,8 @@ interface BeszelTooltipProps {
}
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("common");
const isActive = active && (payload?.length ?? 0) > 0;
if (!isActive) return null;
@@ -66,7 +67,9 @@ const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }:
if (!sorted.length) return null;
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
const tooltipLabel = rawTime
? formatLocalizedDate(rawTime, locale, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
: label;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
return (
@@ -81,7 +84,7 @@ const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }:
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.name}>{t("total")}</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
@@ -100,6 +103,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
showTotal={showTotal}
/>
),
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
wrapperStyle: { pointerEvents: "none" as const },
isAnimationActive: false,
});
@@ -2,6 +2,12 @@
background-color: var(--mantine-color-primaryColor-light-hover);
}
.bookmarkButton:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
border-radius: var(--mantine-radius-sm);
}
[data-mantine-color-scheme="light"] .card-grid {
background-color: var(--mantine-color-gray-1);
}
@@ -13,3 +19,7 @@
.card:hover > div > div.bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
.bookmarkButton:focus-visible .bookmarkIcon {
background-color: var(--mantine-color-iconColor-filled-hover);
}
+115 -36
View File
@@ -1,6 +1,18 @@
"use client";
import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import {
Anchor,
Box,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
@@ -12,7 +24,45 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
hideTitle: boolean;
minimumItemSize: number;
}
export function getCompactBookmarkLayout(
width: number,
height: number,
itemCount: number,
layout: BookmarkLayout,
): CompactBookmarkLayout {
const count = Math.max(1, itemCount);
const isGrid = layout === "grid" || layout === "gridHorizontal";
const targetWidth = layout === "gridHorizontal" ? 180 : 104;
const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1;
const rows = Math.max(1, Math.ceil(count / columns));
const cellWidth = width / columns;
const cellHeight = height / rows;
const usesHorizontalItems = layout === "column" || layout === "gridHorizontal";
return {
columns,
hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96),
hideTitle: cellWidth < 64 || cellHeight < 40,
minimumItemSize: usesHorizontalItems ? 48 : 96,
};
}
export default function BookmarksWidget({
options,
itemId,
width,
height,
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(apps) {
@@ -41,10 +91,15 @@ export default function BookmarksWidget({ options, itemId, width, displayMode }:
[data],
);
const compactLayout = getCompactBookmarkLayout(width, height, data.length, options.layout);
const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon);
const compactHideHostname =
options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon));
return (
<Stack h="100%" gap="sm" p="sm">
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
{options.title.length > 0 && (
<Title order={4} px="0.25rem">
<Title order={4} px="0.25rem" lineClamp={1}>
{options.title}
</Title>
)}
@@ -57,29 +112,37 @@ export default function BookmarksWidget({ options, itemId, width, displayMode }:
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
<GridLayout
data={data}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
{displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
<FlexLayout
data={data}
direction={options.layout}
hideTitle={options.hideTitle}
hideIcon={options.hideIcon}
hideHostname={options.hideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
{displayMode !== "advanced" && (
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars style={{ flex: 1, minHeight: 0 }}>
<Box mih="100%">
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
<GridLayout
data={data}
columns={compactLayout.columns}
minimumItemHeight={compactLayout.minimumItemSize}
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
) : (
<FlexLayout
data={data}
direction={options.layout}
minimumItemSize={compactLayout.minimumItemSize}
hideTitle={compactHideTitle}
hideIcon={options.hideIcon}
hideHostname={compactHideHostname}
openNewTab={options.openNewTab}
withBorder={options.withBorder}
hasIconColor={board.iconColor !== null}
/>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
@@ -94,6 +157,7 @@ interface FlexLayoutProps {
openNewTab: boolean;
withBorder: boolean;
hasIconColor: boolean;
minimumItemSize: number;
}
const FlexLayout = ({
@@ -105,13 +169,15 @@ const FlexLayout = ({
openNewTab,
withBorder,
hasIconColor,
minimumItemSize,
}: FlexLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex direction={direction} gap="0" w="100%">
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
@@ -188,6 +254,7 @@ const AdvancedBookmarksLayout = ({
{data.map((app) => (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
@@ -233,6 +300,8 @@ interface GridLayoutProps {
withBorder: boolean;
itemDirection: "horizontal" | "vertical";
hasIconColor: boolean;
columns: number;
minimumItemHeight: number;
}
const GridLayout = ({
@@ -244,19 +313,29 @@ const GridLayout = ({
withBorder,
itemDirection,
hasIconColor,
columns,
minimumItemHeight,
}: GridLayoutProps) => {
const board = useRequiredBoard();
return (
<Flex miw="100%" gap={4} wrap="wrap" style={{ flex: 1 }}>
<SimpleGrid
cols={columns}
spacing={4}
verticalSpacing={4}
miw="100%"
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => (
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
flex="1"
h="100%"
>
<Card
h="100%"
@@ -285,7 +364,7 @@ const GridLayout = ({
</Card>
</UnstyledButton>
))}
</Flex>
</SimpleGrid>
);
};
@@ -305,7 +384,7 @@ const VerticalItem = ({
return (
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
{!hideTitle && (
<Text fw={700} ta="center" size="xs">
<Text fw={700} ta="center" size="xs" lineClamp={2}>
{app.name}
</Text>
)}
@@ -326,7 +405,7 @@ const VerticalItem = ({
/>
)}
{!hideHostname && (
<Anchor ta="center" component="span" size="xs">
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
{getBookmarkHostname(app.href)}
</Anchor>
)}
@@ -365,7 +444,7 @@ const HorizontalItem = ({
)}
{!(hideTitle && hideHostname) && (
<>
<Stack justify="space-between" gap={0}>
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
{!hideTitle && (
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
{app.name}
@@ -373,7 +452,7 @@ const HorizontalItem = ({
)}
{!hideHostname && (
<Anchor component="span" size="xs">
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
)}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
@@ -9,6 +9,22 @@ describe("bookmark display helpers", () => {
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
});
test("uses container geometry to keep compact cards legible", () => {
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
columns: 2,
hideHostname: false,
hideTitle: false,
minimumItemSize: 48,
});
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
columns: 4,
hideHostname: true,
hideTitle: true,
minimumItemSize: 96,
});
});
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
@@ -24,11 +24,17 @@ import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
fillHeight?: boolean;
groupByDate?: boolean;
locale?: string;
}
export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
export const CalendarEventList = ({
events,
fillHeight = false,
groupByDate = false,
locale,
}: CalendarEventListProps) => {
const headingIdPrefix = useId();
const dateFormatter = new Intl.DateTimeFormat(locale, {
weekday: "long",
@@ -43,9 +49,10 @@ export const CalendarEventList = ({ events, groupByDate = false, locale }: Calen
offsetScrollbars
pt={5}
w="100%"
h={fillHeight ? "100%" : undefined}
styles={{
viewport: {
maxHeight: 450,
maxHeight: fillHeight ? undefined : 450,
},
}}
>
@@ -100,9 +107,9 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
)}
</Box>
)}
<Stack style={{ flexGrow: 1 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="nowrap">
<Stack gap={0}>
<Stack style={{ flexGrow: 1, minWidth: 0 }} gap={0}>
<Group justify="space-between" align="start" mb="xs" wrap="wrap">
<Stack gap={0} style={{ flex: "1 1 12rem", minWidth: 0 }}>
{event.subTitle !== null && (
<Text lineClamp={1} size="sm">
{event.subTitle}
@@ -120,7 +127,7 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
</Group>
)}
<Group gap={3} wrap="nowrap" align={"center"}>
<Group gap={3} wrap="nowrap" align={"center"} ml="auto">
<IconClock opacity={0.7} size={"1rem"} />
{isAllDay(event) ? (
<Text c={"dimmed"} size={"sm"}>
@@ -4,6 +4,9 @@ import type { CalendarEvent } from "@homarr/integrations/types";
export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
export const moveCalendarMonth = (month: Date, amount: number): Date =>
dayjs(month).startOf("month").add(amount, "month").toDate();
const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { CalendarEvent } from "@homarr/integrations/types";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
import { getCalendarAgendaEvents, groupEventsByDate, moveCalendarMonth, splitEvents } from "./calendar-events";
describe("splitEvents should split multi-day events into multiple single-day events", () => {
test("2 day all-day event should be split up into two all-day events", () => {
@@ -92,6 +92,13 @@ describe("getCalendarAgendaEvents", () => {
});
});
describe("moveCalendarMonth", () => {
test("uses the first day so short months do not skip", () => {
expect(moveCalendarMonth(new Date(2025, 0, 31), 1)).toEqual(new Date(2025, 1, 1));
expect(moveCalendarMonth(new Date(2025, 2, 31), -1)).toEqual(new Date(2025, 1, 1));
});
});
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
title: "Test",
subTitle: null,
+181 -37
View File
@@ -1,31 +1,64 @@
"use client";
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { Box, Group, Stack, Text, useMantineTheme } from "@mantine/core";
import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import {
getCalendarAgendaEvents,
groupEventsByDate,
moveCalendarMonth,
splitEvents,
toCalendarDateKey,
} from "./calendar-events";
import type { CalendarEventWithSource } from "./calendar-event-list";
import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
const [month, setMonth] = useState(() => new Date());
const runtimeInput = {
integrationIds: props.integrationIds,
month: month.getMonth(),
year: month.getFullYear(),
releaseType: props.options.releaseType,
showUnmonitored: props.options.showUnmonitored,
};
useWidgetRuntimeQueries(
props.widgetRuntimeRef,
props.integrationIds.length > 0
? [getQueryKey(clientApi.widget.calendar.findAllEvents, runtimeInput, "query")]
: [],
);
if (props.integrationIds.length === 0) {
setWidgetRuntimeQueries(props.widgetStateRef, []);
return <CalendarBase {...props} events={[]} failedIntegrations={[]} month={month} setMonth={setMonth} />;
return (
<CalendarBase
{...props}
events={[]}
failedIntegrations={[]}
isPending={false}
queryError={undefined}
month={month}
setMonth={setMonth}
/>
);
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -36,15 +69,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
widgetStateRef,
}: FetchCalendarProps) => {
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -52,10 +77,24 @@ const FetchCalendar = ({
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "calendar", "findAllEvents"], input }]);
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const calendarQuery = clientApi.widget.calendar.findAllEvents.useQuery(input);
const data = getUsableWidgetQueryData(calendarQuery);
const { isPending } = calendarQuery;
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
const events = useMemo(
() =>
data?.flatMap(({ events: integrationEvents, integration, error }) =>
error
? []
: integrationEvents.map(
(event): CalendarEventWithSource => ({
...event,
source: { integrationId: integration.id, integrationName: integration.name },
}),
),
) ?? [],
[data],
);
const failedIntegrations =
data?.flatMap(({ integration, error }) =>
error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [],
@@ -66,6 +105,8 @@ const FetchCalendar = ({
isEditMode={isEditMode}
events={events}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={calendarQuery.error}
month={month}
setMonth={setMonth}
options={options}
@@ -76,8 +117,10 @@ const FetchCalendar = ({
interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEvent[];
events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
isPending: boolean;
queryError: unknown;
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
@@ -88,22 +131,26 @@ const CalendarBase = ({
isEditMode,
events,
failedIntegrations,
isPending,
queryError,
month,
setMonth,
options,
displayMode,
}: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const t = useI18n();
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const isSmall = width < 256;
const calendarHeight = displayMode === "advanced" ? height * 0.62 : height;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const normalizedEvents = useMemo(
() => (displayMode === "advanced" ? [] : splitEvents(events)),
[displayMode, events],
);
const visibleEvents = useMemo(
() =>
normalizedEvents.filter(
@@ -113,15 +160,32 @@ const CalendarBase = ({
);
const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
const agendaEvents = useMemo(
() => getCalendarAgendaEvents(events, month, options.releaseType),
[events, month, options.releaseType],
() => (displayMode === "advanced" ? getCalendarAgendaEvents(events, month, options.releaseType) : []),
[displayMode, events, month, options.releaseType],
);
if (displayMode === "advanced") {
return (
<CalendarAgenda
events={agendaEvents}
failedIntegrations={failedIntegrations}
isPending={isPending}
queryError={queryError}
queryErrorLabel={t("widget.calendar.name")}
isEditMode={isEditMode}
locale={locale}
month={month}
setMonth={setMonth}
/>
);
}
return (
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
{failedIntegrations.length > 0 && (
{(failedIntegrations.length > 0 || Boolean(queryError)) && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={t("widget.calendar.name")} />
</Group>
)}
<Calendar
@@ -137,7 +201,7 @@ const CalendarBase = ({
static={isEditMode}
className={classes.calendar}
w="100%"
h={displayMode === "advanced" ? "62%" : "100%"}
h="100%"
styles={{
calendarHeaderControl: {
pointerEvents: isEditMode ? "none" : undefined,
@@ -191,19 +255,99 @@ const CalendarBase = ({
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
rootHeight={calendarHeight}
rootHeight={height}
/>
);
}}
/>
{displayMode === "advanced" && (
<Box px="md" pb="sm" style={{ flex: 1, minHeight: 0 }}>
<Text size="sm" fw={600} mb={4}>
{new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
</Text>
<CalendarEventList events={agendaEvents} groupByDate locale={locale} />
</Box>
)}
</Stack>
);
};
interface CalendarAgendaProps {
events: CalendarEventWithSource[];
failedIntegrations: CalendarBaseProps["failedIntegrations"];
isPending: boolean;
queryError: unknown;
queryErrorLabel: string;
isEditMode: boolean;
locale: string;
month: Date;
setMonth: (date: Date) => void;
}
const CalendarAgenda = ({
events,
failedIntegrations,
isPending,
queryError,
queryErrorLabel,
isEditMode,
locale,
month,
setMonth,
}: CalendarAgendaProps) => {
const t = useScopedI18n("widget.calendar.advanced");
const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month);
return (
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Tooltip label={t("previousMonth")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="lg"
aria-label={t("previousMonth")}
disabled={isEditMode}
onClick={() => setMonth(moveCalendarMonth(month, -1))}
>
<IconChevronLeft size={18} aria-hidden />
</ActionIcon>
</Tooltip>
<Stack gap={0} miw={0}>
<Text component="h3" size="sm" fw={600} tt="capitalize" lineClamp={1}>
{monthLabel}
</Text>
<Text size="xs" c="dimmed">
{isPending ? t("loading") : t("eventCount", { count: events.length })}
</Text>
</Stack>
<Tooltip label={t("nextMonth")} events={{ hover: true, focus: true, touch: false }}>
<ActionIcon
className={actionTargetClasses.root}
variant="subtle"
size="lg"
aria-label={t("nextMonth")}
disabled={isEditMode}
onClick={() => setMonth(moveCalendarMonth(month, 1))}
>
<IconChevronRight size={18} aria-hidden />
</ActionIcon>
</Tooltip>
</Group>
<Group gap={0}>
<IntegrationErrorIndicator results={failedIntegrations} />
<WidgetQueryErrorIndicator error={queryError} label={queryErrorLabel} />
</Group>
</Group>
<Box style={{ flex: 1, minHeight: 0 }}>
{isPending ? (
<Center component="output" h="100%" aria-label={t("loading")}>
<Loader size="sm" />
</Center>
) : events.length > 0 ? (
<CalendarEventList events={events} advanced groupByDate locale={locale} fillHeight />
) : (
<Stack h="100%" align="center" justify="center" p="md">
<Text c="dimmed" size="sm" ta="center">
{t("noEvents")}
</Text>
</Stack>
)}
</Box>
</Stack>
);
};
+22 -3
View File
@@ -14,6 +14,7 @@ export default function CoolifyWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
@@ -31,6 +32,7 @@ export default function CoolifyWidget({
integrationIds={integrationIds}
options={options}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
@@ -40,16 +42,31 @@ interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
height: number;
isAdvanced: boolean;
}
function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) {
const t = useScopedI18n("common");
const { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
});
const isTiny = !isAdvanced && width < 256;
const isTiny = !isAdvanced && (width < 256 || height < 144);
const hideFooter = !isAdvanced && height < 112;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (isPending) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("action.loading")}
</Text>
</Stack>
);
}
if (instancesData.length === 1 && firstInstance) {
return (
<SingleInstanceLayout
@@ -58,6 +75,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
hideFooter={hideFooter}
/>
);
}
@@ -73,6 +91,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
hideFooter={hideFooter}
/>
))}
</SimpleGrid>
+80 -45
View File
@@ -1,14 +1,15 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
import type { CoolifyOptions, InstanceData } from "./types";
@@ -18,12 +19,14 @@ interface InstanceCardProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const tCommon = useScopedI18n("common");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${cardKey}`,
@@ -33,44 +36,41 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length;
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
const runningApps = instance.instanceInfo.applications.filter(
(a) => parseStatus(a.status ?? "") === "running",
).length;
const runningServices = instance.instanceInfo.services.filter(
(s) => parseStatus(s.status ?? "") === "running",
).length;
const healthyResources =
(options.showServers ? onlineServers : 0) +
(options.showApplications ? runningApps : 0) +
(options.showServices ? runningServices : 0);
const totalResources =
(options.showServers ? instance.instanceInfo.servers.length : 0) +
(options.showApplications ? instance.instanceInfo.applications.length : 0) +
(options.showServices ? instance.instanceInfo.services.length : 0);
const resourceSummary = [
options.showServers ? `${tCommon("servers")}: ${onlineServers}/${instance.instanceInfo.servers.length}` : null,
options.showApplications
? `${tCommon("applications")}: ${runningApps}/${instance.instanceInfo.applications.length}`
: null,
options.showServices ? `${tCommon("services")}: ${runningServices}/${instance.instanceInfo.services.length}` : null,
]
.filter(Boolean)
.join(" · ");
return (
<Card p={0} radius="sm">
@@ -80,19 +80,42 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
wrap="nowrap"
style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}
>
<Group gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" miw={0}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "10px" : "xs"} fw={600} c="inherit" lineClamp={1}>
{instance.integrationName}
</Anchor>
<Stack gap={0} miw={0}>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
{isAdvanced && (
<Text fz="10px" c="dimmed" truncate="end">
{t("source.url", { url: displayUrl })}
</Text>
)}
</Stack>
</Group>
<Group gap={4} wrap="nowrap">
{options.showServers && (
{isTiny && totalResources > 0 ? (
<Tooltip label={resourceSummary}>
<Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs">
{healthyResources}/{totalResources}
</Badge>
</Tooltip>
) : null}
{!isTiny && options.showServers && (
<Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs">
{onlineServers}/{instance.instanceInfo.servers.length}
</Badge>
)}
{options.showApplications && (
{!isTiny && options.showApplications && (
<Badge
variant="dot"
color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)}
@@ -101,7 +124,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
{runningApps}/{instance.instanceInfo.applications.length}
</Badge>
)}
{options.showServices && (
{!isTiny && options.showServices && (
<Badge
variant="dot"
color={getBadgeColor(runningServices, instance.instanceInfo.services.length)}
@@ -126,6 +149,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -134,18 +158,29 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
)}
</Accordion>
<Group justify="space-between" p={4} style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
)}
</Card>
);
}
+36 -14
View File
@@ -1,7 +1,7 @@
"use client";
import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconFileText, IconLink } from "@tabler/icons-react";
import { IconFileText, IconLink } from "@tabler/icons-react";
import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
@@ -39,7 +39,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={statusColor} />
{resourceUrl ? (
<Anchor href={resourceUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
<Anchor
href={resourceUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "8px" : "xs"}
c="inherit"
lineClamp={1}
>
{item.name}
</Anchor>
) : (
@@ -50,24 +57,39 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
{cleanFqdn(item.fqdn) && (
<ActionIcon component="a" href={cleanFqdn(item.fqdn)} target="_blank" size="xs" variant="subtle" c="dimmed">
<ActionIcon
component="a"
href={cleanFqdn(item.fqdn)}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${item.name}`}
size="xs"
variant="subtle"
c="dimmed"
>
<IconLink size={12} />
</ActionIcon>
)}
{resourceUrl && (
<ActionIcon component="a" href={resourceUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
)}
{logsUrl && (
<ActionIcon component="a" href={logsUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
{!isTiny && logsUrl && (
<ActionIcon
component="a"
href={logsUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open ${item.name} logs`}
size="xs"
variant="subtle"
c="dimmed"
>
<IconFileText size={12} />
</ActionIcon>
)}
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
{getResourceTimestamp(item, resourceType) && (
{!isTiny && (
<Text fz="10px" c="dimmed" lineClamp={1}>
{item.projectName ?? "-"} / {item.environmentName ?? "-"}
</Text>
)}
{!isTiny && getResourceTimestamp(item, resourceType) && (
<Text fz="10px" c="dimmed" ml="auto">
{getResourceTimestamp(item, resourceType)}
</Text>
@@ -1,7 +1,7 @@
"use client";
import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core";
import { IconExternalLink, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import { IconEye, IconEyeOff, IconServer } from "@tabler/icons-react";
import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
@@ -40,6 +40,7 @@ export function ServersSection({
</Badge>
</Group>
<ActionIcon
aria-label={`${showIp ? tCommon("action.hide") : tCommon("action.show")} IP`}
size="xs"
variant="subtle"
c="dimmed"
@@ -96,7 +97,14 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={isOnline ? "green" : "red"} />
<Anchor href={serverUrl} target="_blank" fz={isTiny ? "8px" : "xs"} c="inherit" lineClamp={1}>
<Anchor
href={serverUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "8px" : "xs"}
c="inherit"
lineClamp={1}
>
{server.name}
</Anchor>
{isBuildServer ? (
@@ -110,9 +118,6 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
)}
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<ActionIcon component="a" href={serverUrl} target="_blank" size="xs" variant="subtle" c="dimmed">
<IconExternalLink size={12} />
</ActionIcon>
<Text fz="10px" c="dimmed">
{showIp ? server.ip : "***.***.***.***"}
</Text>
@@ -20,9 +20,17 @@ interface SingleInstanceLayoutProps {
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
hideFooter: boolean;
}
export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isAdvanced }: SingleInstanceLayoutProps) {
export function SingleInstanceLayout({
instance,
options,
isTiny,
widgetKey,
isAdvanced,
hideFooter,
}: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
const [showIp, setShowIp] = useLocalStorage({
key: `coolify-show-ip-${widgetKey}`,
@@ -74,7 +82,15 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
oolify
</Text>
</Group>
<Anchor href={baseUrl} target="_blank" fz={isTiny ? "xs" : "sm"} fw={500} c="dimmed" lineClamp={1}>
<Anchor
href={baseUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{displayUrl}
</Anchor>
</Group>
@@ -104,21 +120,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
)}
</Accordion>
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
</Text>
</Group>
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
)}
</Stack>
</ScrollArea>
);
@@ -53,7 +53,7 @@ export default function DnsHoleControlsWidget({
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
const { data: summaries = [], isPending: isSummaryPending } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const utils = clientApi.useUtils();
@@ -129,6 +129,16 @@ export default function DnsHoleControlsWidget({
setBulkPending(false);
};
if (isSummaryPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
</Text>
</Stack>
);
}
return (
<Stack
className="dns-hole-controls-stack"
@@ -142,6 +152,7 @@ export default function DnsHoleControlsWidget({
<Flex className="dns-hole-controls-buttons" gap="sm">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.enableAll")}
size="xs"
p={0}
className="dns-hole-controls-enable-all-button"
@@ -159,6 +170,7 @@ export default function DnsHoleControlsWidget({
<Tooltip label={t("widget.dnsHoleControls.controls.setTimer")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.setTimer")}
size="xs"
p={0}
className="dns-hole-controls-timer-all-button"
@@ -179,6 +191,7 @@ export default function DnsHoleControlsWidget({
<Tooltip label={t("widget.dnsHoleControls.controls.disableAll")}>
<Button
aria-label={t("widget.dnsHoleControls.controls.disableAll")}
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
@@ -340,6 +353,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
<Group gap="xs" grow wrap="nowrap" w="100%">
{!isEnabled ? (
<ActionIcon
aria-label={`${t("widget.dnsHoleControls.controls.enableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
@@ -350,6 +364,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
) : (
<ActionIcon
aria-label={`${t("widget.dnsHoleControls.controls.disableAll")}: ${data.integration.name}`}
onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
@@ -360,6 +375,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</ActionIcon>
)}
<ActionIcon
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
onClick={() => {
setSelectedIntegrationIds([data.integration.id]);
open();
@@ -376,6 +392,11 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
)}
{layout === "md" && (
<UnstyledButton
aria-label={`${
isEnabled
? t("widget.dnsHoleControls.controls.disableAll")
: t("widget.dnsHoleControls.controls.enableAll")
}: ${data.integration.name}`}
className="dns-hole-controls-item-toggle-button"
disabled={!controlEnabled}
display="contents"
@@ -418,6 +439,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
</Flex>
{layout === "md" && (
<ActionIcon
aria-label={`${t("widget.dnsHoleControls.controls.setTimer")}: ${data.integration.name}`}
className="dns-hole-controls-item-timer-button"
display={isInteractPermitted ? undefined : "none"}
disabled={!controlEnabled || !isEnabled}
@@ -36,7 +36,7 @@ export default function DnsHoleSummaryWidget({
integrationIds,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
const { data: summaries = [], isPending } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -45,6 +45,16 @@ export default function DnsHoleSummaryWidget({
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const layoutProps = boxPropsByLayout(options.layout);
if (isPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
</Text>
</Stack>
);
}
return (
<Stack h="100%" gap={0}>
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
@@ -53,7 +63,16 @@ export default function DnsHoleSummaryWidget({
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
))
) : (
<Stack h="100%" w="100%" justify="center" align="center" gap="sm" p="sm">
<Stack
aria-live="polite"
h="100%"
w="100%"
justify="center"
align="center"
gap="sm"
p="sm"
style={{ gridColumn: "1 / -1" }}
>
<AvatarGroup spacing="md">
{summaries.map(({ integration }) => (
<Tooltip key={integration.id} label={integration.name}>
@@ -154,11 +173,15 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
const tooltip = item.tooltip?.(data, t);
const board = useRequiredBoard();
const label = translateIfNecessary(t, item.label);
const value = item.value(data, width <= 64 ? "sm" : "md");
return (
<TooltipFloating label={tooltip} disabled={!tooltip} w={250} multiline>
<Card
ref={ref}
component="section"
aria-label={`${label}: ${value}`}
className="summary-card"
p="sm"
radius={board.itemRadius}
@@ -190,11 +213,11 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
wrap="wrap"
>
<Text className="summary-card-value text-flash" ta="center" size="lg" fw="bold" maw="100%">
{item.value(data, width <= 64 ? "sm" : "md")}
{value}
</Text>
{!hideLabel && (
<Text className="summary-card-label" ta="center" size="xs" maw="100%">
{translateIfNecessary(t, item.label)}
{label}
</Text>
)}
</Flex>
+19 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getDockerColumnVisibility } from "./layout";
import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout";
const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
@@ -8,7 +8,7 @@ describe("getDockerColumnVisibility", () => {
test("keeps only essential columns in a narrow compact widget", () => {
expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
name: true,
state: true,
state: false,
host: false,
cpuUsage: false,
memoryUsage: false,
@@ -17,13 +17,17 @@ describe("getDockerColumnVisibility", () => {
});
test("reveals compact metrics as width becomes available", () => {
expect(getDockerColumnVisibility(columns, 380, false)).toMatchObject({
expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({
host: false,
cpuUsage: true,
memoryUsage: true,
memoryUsage: false,
});
});
test("keeps a configured state column when there is no name column for the inline dot", () => {
expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true);
});
test("uses every expert column in advanced mode", () => {
expect(getDockerColumnVisibility([], 240, true)).toEqual({
name: true,
@@ -35,3 +39,14 @@ describe("getDockerColumnVisibility", () => {
});
});
});
describe("getDockerFooterVisibility", () => {
test("reveals totals only when they fit", () => {
expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false });
expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false });
});
test("keeps every total in advanced mode", () => {
expect(getDockerFooterVisibility(240, true)).toEqual({ footer: true, cpu: true, memory: true });
});
});
+10 -4
View File
@@ -10,10 +10,16 @@ export const getDockerColumnVisibility = (
return {
name: isVisible("name"),
state: isVisible("state"),
host: isVisible("host") && (isAdvanced || width >= 440),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 280),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 360),
state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")),
host: isVisible("host") && (isAdvanced || width >= 640),
cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420),
memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520),
actions: isVisible("actions"),
};
};
export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({
footer: isAdvanced || width > 256,
cpu: isAdvanced || width >= 360,
memory: isAdvanced || width >= 480,
});
+46 -24
View File
@@ -50,12 +50,15 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
export default function FirewallWidget({
integrationIds,
width,
height,
itemId,
displayMode,
}: WidgetComponentProps<"firewall">) {
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const isTiny = !isAdvanced && (width < 256 || height < 180);
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
const showInterfaces = isAdvanced || height >= 120;
const t = useI18n();
const handleSelect = useCallback((value: string | null) => {
@@ -102,6 +105,17 @@ export default function FirewallWidget({
value: firewallId,
}));
if (firewallIds.length === 0) {
const isLoading = queries.some((query) => query.isPending || query.isFetching);
return (
<Center h="100%" p="sm">
<Text size="sm" c="dimmed" ta="center">
{isLoading ? t("common.action.loading") : t("widget.firewall.empty.noInterfaces")}
</Text>
</Center>
);
}
return (
<ScrollArea h="100%">
<Stack gap="xs" p={isAdvanced ? "xs" : 0}>
@@ -146,6 +160,8 @@ export default function FirewallWidget({
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
ringSize={ringSize}
showInterfaces={showInterfaces}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={t("widget.firewall.error.internalServerError")}
@@ -175,6 +191,8 @@ interface FirewallPanelProps {
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
ringSize: number;
showInterfaces: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
@@ -197,6 +215,8 @@ const FirewallPanel = ({
hasError,
isAdvanced,
isTiny,
ringSize,
showInterfaces,
accordionValue,
setAccordionValue,
errorLabel,
@@ -237,33 +257,35 @@ const FirewallPanel = ({
<Flex justify="center" align="center" wrap="wrap">
{cpu !== undefined && (
<MetricRing value={cpu} icon={IconCpu} isTiny={isTiny} label={t("widget.firewall.widget.cpu")} t={t} />
<MetricRing value={cpu} icon={IconCpu} size={ringSize} label={t("widget.firewall.widget.cpu")} t={t} />
)}
{memory !== undefined && (
<MetricRing
value={memory}
icon={IconBrain}
isTiny={isTiny}
size={ringSize}
label={t("widget.firewall.widget.memory")}
t={t}
/>
)}
</Flex>
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
{showInterfaces && (
<InterfacesPanel
summary={interfaces ?? []}
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
errorBadgeLabel={errorBadgeLabel}
noDataLabel={noDataLabel}
loadingLabel={loadingLabel}
label={interfacesLabel}
/>
)}
</Stack>
</Paper>
);
@@ -271,12 +293,12 @@ const FirewallPanel = ({
interface MetricRingProps {
value: number;
icon: typeof IconCpu;
isTiny: boolean;
size: number;
label: string;
t: TranslationFunction;
}
const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
@@ -289,13 +311,13 @@ const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) =>
status: statusLabel,
})}
roundCaps
size={isTiny ? 50 : 100}
thickness={isTiny ? 4 : 8}
size={size}
thickness={size < 72 ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={isTiny ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={isTiny ? 8 : 16} />
{!isTiny && <Text size="8px">{statusLabel}</Text>}
<Text size={size < 72 ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={size < 72 ? 8 : 16} />
{size >= 72 && <Text size="8px">{statusLabel}</Text>}
</Center>
}
sections={[
@@ -10,16 +10,18 @@ interface FirewallMenuProps {
}
export const FirewallMenu = ({ onChange, isTiny, dropdownItems, selectedFirewall }: FirewallMenuProps) => (
<Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Select
value={selectedFirewall}
onChange={onChange}
size={isTiny ? "8px" : "xs"}
size="xs"
w="100%"
color="lightgray"
data={dropdownItems}
styles={{
input: {
minHeight: "24px",
paddingInline: isTiny ? 6 : undefined,
},
}}
/>
@@ -29,8 +29,9 @@ export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny
autoContrast
variant="outline"
color="lightgray"
size={isTiny ? "8px" : "xs"}
style={{ minHeight: "24px" }}
size="xs"
style={{ minHeight: "24px", maxWidth: isTiny ? 72 : undefined }}
styles={{ label: { overflow: "hidden", textOverflow: "ellipsis" } }}
>
{formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
</Badge>
@@ -69,7 +69,7 @@ export const ClusterHealthMonitoring = ({
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && (
<Group justify="center" wrap="nowrap">
<Text fz={isTiny ? 8 : "xs"} tt="uppercase" fw={700} c="dimmed" ta="center">
<Text fz={isTiny ? 8 : "xs"} fw={700} c="dimmed" ta="center">
{formatUptime(uptime, t)}
</Text>
</Group>
@@ -5,3 +5,35 @@
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
.systemPanel {
min-width: 0;
}
.infoAction {
transition: opacity 150ms ease;
}
.infoActionCompact {
opacity: 0;
pointer-events: none;
}
.systemPanel:hover .infoActionCompact,
.systemPanel:focus-within .infoActionCompact {
opacity: 1;
pointer-events: auto;
}
@media (hover: none) {
.infoActionCompact {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.infoAction {
transition: none;
}
}
@@ -83,16 +83,29 @@ export const SystemHealthMonitoring = ({
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
const hasAttentionState = healthInfo.rebootRequired || healthInfo.availablePkgUpdates > 0;
return (
<Stack
gap="sm"
key={integrationId}
h={!isAdvanced && healthData.length === 1 ? "100%" : "auto"}
className={`health-monitoring-information health-monitoring-${integrationName}`}
className={combineClasses(
`health-monitoring-information health-monitoring-${integrationName}`,
classes.systemPanel,
)}
p="sm"
pos="relative"
>
<Box className="health-monitoring-information-card-section" pos="absolute" top={8} right={8}>
<Box
className={combineClasses(
"health-monitoring-information-card-section",
classes.infoAction,
!isAdvanced && !hasAttentionState && classes.infoActionCompact,
)}
pos="absolute"
top={8}
right={8}
>
<Indicator
className="health-monitoring-updates-reboot-indicator"
inline
@@ -24,29 +24,42 @@
border-radius: 0;
}
.dots {
.carouselControls {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
inset-block-start: 8px;
inset-inline-end: 8px;
z-index: 1;
opacity: 0;
pointer-events: none;
transform: translateY(-2px);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all 0.2s ease;
.carouselContainer:hover .carouselControls,
.carouselContainer:focus-within .carouselControls,
.carouselControls[data-visible="true"] {
opacity: 1;
pointer-events: auto;
transform: none;
}
&[data-active="true"] {
background-color: rgba(255, 255, 255, 1);
width: 24px;
border-radius: 4px;
@media (any-pointer: coarse) {
.carouselControls {
opacity: 1;
pointer-events: auto;
transform: none;
}
&:hover {
background-color: rgba(255, 255, 255, 0.8);
.carouselControls button {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.carouselControls {
transition: none;
}
}
@@ -161,41 +161,48 @@ function Carousel({
<Box w="100%" style={{ flex: 1, minHeight: 0 }} className={classes.carouselContainer}>
<Image src={currentAsset.publicLink} alt={t("albumPhoto")} className={classes.carouselImage} />
{advanced && (
<>
<ActionIcon
aria-label={t("actions.previousPhoto")}
variant="filled"
radius="xl"
pos="absolute"
left="md"
top="50%"
onClick={() => move(-1)}
>
<IconChevronLeft size={18} />
</ActionIcon>
<Group pos="absolute" right="md" top="md" gap="xs">
{assets.length > 1 && (
<Group
className={classes.carouselControls}
data-visible={advanced || paused || undefined}
gap={4}
wrap="nowrap"
>
{advanced && (
<ActionIcon
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
aria-label={t("actions.previousPhoto")}
variant="filled"
color="dark"
radius="xl"
onClick={() => setPaused((value) => !value)}
size={40}
onClick={() => move(-1)}
>
{paused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
<IconChevronLeft size={18} />
</ActionIcon>
</Group>
)}
<ActionIcon
aria-label={t("actions.nextPhoto")}
aria-label={paused ? t("actions.resumeSlideshow") : t("actions.pauseSlideshow")}
variant="filled"
color="dark"
radius="xl"
pos="absolute"
right="md"
top="50%"
onClick={() => move(1)}
size={advanced ? 40 : 32}
onClick={() => setPaused((value) => !value)}
>
<IconChevronRight size={18} />
{paused ? <IconPlayerPlay size={18} /> : <IconPlayerPause size={18} />}
</ActionIcon>
</>
{advanced && (
<ActionIcon
aria-label={t("actions.nextPhoto")}
variant="filled"
color="dark"
radius="xl"
size={40}
onClick={() => move(1)}
>
<IconChevronRight size={18} />
</ActionIcon>
)}
</Group>
)}
{(showPhotoInfo || advanced) && (
@@ -8,3 +8,11 @@
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.statItemDense {
min-width: 0;
min-height: 44px;
padding: 4px 6px;
border-radius: var(--mantine-radius-sm);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
@@ -20,6 +20,7 @@ export default function ImmichServerStatsWidget({
options,
displayMode = "compact",
width,
height,
widgetStateRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
@@ -39,16 +40,26 @@ export default function ImmichServerStatsWidget({
if (!stats) return <WidgetEmptyState />;
const statCount =
Number(options.showUsers) + Number(options.showPhotos) + Number(options.showVideos) + Number(options.showStorage);
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
const statsContent = (
<SimpleGrid cols={isAdvanced ? (width >= 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1} spacing="sm">
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{options.showUsers && (
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
<StatItem
icon={<IconUsers size={20} />}
label={t("widget.immich-serverStats.users")}
value={stats.userCount}
dense={statsLayout.dense}
/>
)}
{options.showPhotos && (
<StatItem
icon={<IconPhoto size={20} />}
label={t("widget.immich-serverStats.photos")}
value={stats.photoCount}
dense={statsLayout.dense}
/>
)}
{options.showVideos && (
@@ -56,6 +67,7 @@ export default function ImmichServerStatsWidget({
icon={<IconVideo size={20} />}
label={t("widget.immich-serverStats.videos")}
value={stats.videoCount}
dense={statsLayout.dense}
/>
)}
{options.showStorage && (
@@ -63,6 +75,7 @@ export default function ImmichServerStatsWidget({
icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")}
value={formatBytes(stats.totalLibraryUsageInBytes)}
dense={statsLayout.dense}
/>
)}
</SimpleGrid>
@@ -70,7 +83,7 @@ export default function ImmichServerStatsWidget({
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p="md" justify="center">
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center">
{statsContent}
</Stack>
);
@@ -106,20 +119,46 @@ interface StatItemProps {
icon: React.ReactNode;
label: string;
value: string | number;
dense: boolean;
}
function StatItem({ icon, label, value }: StatItemProps) {
function StatItem({ icon, label, value, dense }: StatItemProps) {
if (dense) {
return (
<Stack gap={1} align="center" justify="center" className={classes.statItemDense} title={`${label}: ${value}`}>
<Text size="sm" fw={700} truncate="end" w="100%" ta="center">
{value}
</Text>
<Text size="xs" c="dimmed" truncate="end" w="100%" ta="center">
{label}
</Text>
</Stack>
);
}
return (
<Group justify="space-between" align="center" className={classes.statItem}>
<Group gap="sm" align="center">
<Group gap="sm" align="center" wrap="nowrap" miw={0}>
{icon}
<Text size="sm" fw={500}>
<Text size="sm" fw={500} truncate="end">
{label}
</Text>
</Group>
<Text size="sm" fw={700} c="var(--mantine-primary-color)">
<Text size="sm" fw={700} c="var(--mantine-primary-color)" truncate="end" title={String(value)}>
{value}
</Text>
</Group>
);
}
export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => {
const count = Math.max(1, itemCount);
if (isAdvanced) {
return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false };
}
const dense = width < 320 || height < 160;
if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true };
if (height < 220) return { columns: Math.min(count, 2), dense: true };
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
};
@@ -0,0 +1,15 @@
import { describe, expect, test } from "vitest";
import { getImmichStatsLayout } from "./component";
describe("Immich server statistics compact layout", () => {
test("keeps four enabled stats legible in a short widget", () => {
expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true });
expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true });
});
test("uses the roomier summary layout when space is available", () => {
expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false });
expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true });
});
});
@@ -5,3 +5,44 @@
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
.root {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.testAction {
margin-inline-start: auto;
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
.root:hover .testAction,
.root:focus-within .testAction {
opacity: 1;
}
.indexerRow {
min-height: 24px;
padding: 2px 4px;
border-radius: var(--mantine-radius-xs);
&:hover,
&:focus-within {
background-color: var(--mantine-color-default-hover);
}
}
@media (hover: none) {
.testAction {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.testAction {
transition: none;
}
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo } from "react";
import { ActionIcon, Anchor, Badge, Button, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -31,7 +31,7 @@ export default function IndexerManagerWidget({
const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const hasSmallHeight = !isAdvanced && height < 256;
const isDense = !isAdvanced && (width < 280 || height < 180);
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
const unavailableCount = allIndexers.filter(
(indexer) => indexer.status === false || indexer.enabled === false,
@@ -49,16 +49,26 @@ export default function IndexerManagerWidget({
}, [integrationIds, testAll, widgetStateRef]);
return (
<Flex className="indexer-manager-container" h="100%" direction="column" gap="sm" p="sm" align="center">
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap">
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
/>
<Text size={hasSmallWidth ? "xs" : "md"} fw="bold">
{t("widget.indexerManager.title")}
</Text>
<Flex
className={`indexer-manager-container ${classes.root}`}
h="100%"
direction="column"
gap={isDense ? 4 : "xs"}
p={isDense ? "xs" : "sm"}
>
<Group className="indexer-manager-title" align="center" gap="xs" wrap="nowrap" w="100%">
<Tooltip label={t("widget.indexerManager.title")} disabled={!hasSmallWidth}>
<IconReportSearch
className="indexer-manager-title-icon"
size={hasSmallWidth ? 16 : 20}
style={{ minWidth: hasSmallWidth ? 16 : 20 }}
/>
</Tooltip>
{!hasSmallWidth && (
<Text size={isDense ? "xs" : "sm"} fw={600} truncate="end">
{t("widget.indexerManager.title")}
</Text>
)}
{isAdvanced && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
@@ -71,9 +81,9 @@ export default function IndexerManagerWidget({
)}
</Group>
)}
{hasSmallHeight && (
<Tooltip label={t("widget.indexerManager.testAll")}>
<ActionIcon
className="indexer-manager-test-action-icon"
className={combineClasses("indexer-manager-test-action-icon", classes.testAction)}
size="sm"
radius={board.itemRadius}
variant="light"
@@ -83,15 +93,16 @@ export default function IndexerManagerWidget({
onClick={() => {
testAll({ integrationIds });
}}
aria-label={t("widget.indexerManager.testAll")}
>
<IconTestPipe size={12} />
<IconTestPipe size={14} />
</ActionIcon>
)}
</Tooltip>
</Group>
<Card
className={combineClasses("indexer-manager-list-container", classes.card)}
w="100%"
p="xs"
p={isDense ? 4 : "xs"}
radius={board.itemRadius}
flex={1}
>
@@ -114,7 +125,7 @@ export default function IndexerManagerWidget({
)}
{indexers.map((indexer) => (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name}`}
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
@@ -124,8 +135,15 @@ export default function IndexerManagerWidget({
className="indexer-manager-line-anchor"
href={indexer.url}
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
rel={options.openIndexerSiteInNewTab ? "noopener noreferrer" : undefined}
style={{ minWidth: 0, flex: 1 }}
>
<Text className="indexer-manager-line-anchor-text" c="dimmed" size={hasSmallWidth ? "xs" : "sm"}>
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
>
{indexer.name}
</Text>
</Anchor>
@@ -153,24 +171,6 @@ export default function IndexerManagerWidget({
))}
</ScrollArea>
</Card>
{!hasSmallHeight && (
<Button
className="indexer-manager-test-button"
w="100%"
size="xs"
radius={board.itemRadius}
variant="light"
leftSection={<IconTestPipe size={"1rem"} />}
loading={isPending}
disabled={isEditMode}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
}}
>
{t("widget.indexerManager.testAll")}
</Button>
)}
</Flex>
);
}
@@ -5,10 +5,10 @@
text-decoration: none;
color: inherit;
border: 1px solid var(--border-color);
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
transition:
border-color 120ms ease,
transform 120ms ease,
box-shadow 120ms ease;
background-color 120ms ease;
@mixin dark {
--border-color: var(--mantine-color-dark-4);
@@ -17,22 +17,16 @@
--border-color: var(--mantine-color-gray-3);
}
&:hover {
&:hover,
&:focus-visible {
border-color: var(--mantine-primary-color-filled);
transform: translateY(-1px);
box-shadow: var(--mantine-shadow-sm);
background-color: var(--mantine-color-default-hover);
}
}
.backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: blur(20px) saturate(1.2);
transform: scale(1.3);
opacity: 0.16;
pointer-events: none;
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.content {
@@ -47,3 +41,9 @@
object-fit: cover;
box-shadow: var(--mantine-shadow-sm);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
@@ -0,0 +1,24 @@
.item {
display: block;
width: 100%;
min-width: 0;
color: inherit;
text-align: start;
transition: background-color 120ms ease;
&:hover,
&:focus-visible {
background-color: var(--mantine-color-default-hover);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
}
}
@@ -29,11 +29,13 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function MediaReleasesWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
@@ -48,7 +50,7 @@ export default function MediaReleasesWidget({
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} />
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
@@ -60,25 +62,30 @@ interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
isAdvanced: boolean;
width: number;
height: number;
}
const formatReleaseDate = (value: unknown, locale: string) => {
const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => {
const date = toValidDate(value);
if (!date) return "—";
return Intl.DateTimeFormat(locale, {
month: "2-digit",
month: compact ? "short" : "2-digit",
year: "numeric",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }),
}).format(date);
};
const Item = ({ item, options, isAdvanced }: ItemProps) => {
const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
const length = formatLength(item.length, item.type, t);
const isCompact = !isAdvanced && (width < 340 || height < 180);
const isTiny = !isAdvanced && (width < 220 || height < 105);
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
return (
<TooltipFloating
@@ -93,13 +100,15 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => {
}
>
<UnstyledButton
className={classes.item}
component="a"
href={item.href}
target="_blank"
rel="noopener noreferrer"
pos="relative"
p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
h="100%"
title={item.title}
style={
isAdvanced
? {
@@ -126,57 +135,63 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => {
}}
/>
)}
<Group justify="space-between" h="100%" wrap="nowrap">
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
{(isAdvanced || options.layout === "poster") && (
<Image w={isAdvanced ? 80 : 60} src={item.imageUrls.poster} alt={item.title} radius="xs" />
<Group justify="space-between" h="100%" wrap="nowrap" gap={isCompact ? "xs" : "sm"}>
<Group align="start" wrap="nowrap" style={{ zIndex: 0, minWidth: 0, flex: 1 }} gap="xs">
{showPoster && (
<Image
w={isAdvanced ? 80 : isCompact ? 44 : 60}
src={item.imageUrls.poster}
alt=""
radius="xs"
style={{ flexShrink: 0 }}
/>
)}
<Stack gap={4}>
<Stack gap={isCompact ? 2 : 4} style={{ minWidth: 0, flex: 1 }}>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={2}>
<Text size={isTiny ? "xs" : "sm"} fw="bold" lineClamp={isCompact ? 1 : 2}>
{item.title}
</Text>
{item.subtitle !== undefined && (
<Text size="sm" lineClamp={1}>
{item.subtitle !== undefined && !isTiny && (
<Text size={isCompact ? "xs" : "sm"} c={isCompact ? "dimmed" : undefined} lineClamp={1}>
{item.subtitle}
</Text>
)}
</Stack>
<Group gap={6} style={{ rowGap: 0 }}>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale)} />
{length !== undefined && (
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
<Info icon={IconCalendar} label={formatReleaseDate(item.releaseDate, locale, isCompact)} />
{length !== undefined && !isTiny && (
<>
<InfoDivider />
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
</>
)}
{item.producer !== undefined && (
{item.producer !== undefined && showExtendedMetadata && (
<>
<InfoDivider />
<Info label={item.producer} />
</>
)}
{item.rating !== undefined && (
{item.rating !== undefined && showExtendedMetadata && (
<>
<InfoDivider />
<Info icon={IconStarFilled} label={item.rating} />
</>
)}
{item.price !== undefined && (
{item.price !== undefined && showExtendedMetadata && (
<>
<InfoDivider />
<Info label={`$${item.price.toFixed(2)}`} />
</>
)}
</Group>
{item.tags.length > 0 && (
{item.tags.length > 0 && (isAdvanced || !isCompact) && (
<OverflowBadge
size="xs"
groupGap={4}
data={item.tags}
overflowCount={3}
disablePopover={!isAdvanced}
style={{ cursor: "pointer" }}
style={{ cursor: isAdvanced ? "pointer" : undefined }}
/>
)}
{isAdvanced && item.description && (
@@ -186,7 +201,7 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => {
)}
</Stack>
</Group>
{(isAdvanced || options.showType || options.showSource) && (
{showSide && (
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
{(isAdvanced || options.showType) && (
<Badge
@@ -223,9 +238,9 @@ const InfoDivider = () => (
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
return (
<Group gap={4}>
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} />}
<Text size="xs" c="gray.5">
<Group gap={4} wrap="nowrap" miw={0}>
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} style={{ flexShrink: 0 }} />}
<Text size="xs" c="dimmed" truncate="end">
{label}
</Text>
</Group>
@@ -0,0 +1,45 @@
.card {
min-width: 0;
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
transition:
background-color 120ms ease,
border-color 120ms ease;
&:hover,
&:focus-within {
border-color: var(--mantine-primary-color-filled);
background-color: var(--mantine-color-default-hover);
}
}
.pendingActions {
flex-shrink: 0;
opacity: 0;
transition: opacity 120ms ease;
}
.card:hover .pendingActions,
.card:focus-within .pendingActions,
.pendingActionsVisible {
opacity: 1;
}
@media (hover: none) {
.pendingActions {
opacity: 1;
}
}
@media (pointer: coarse) {
.pendingActions button {
min-width: 36px;
min-height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.card,
.pendingActions {
transition: none;
}
}
@@ -17,7 +17,8 @@ import { WidgetEmptyState } from "../../common/empty-state";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "../search-button.module.css";
import classes from "./component.module.css";
import searchClasses from "../search-button.module.css";
export default function MediaServerWidget({
integrationIds,
@@ -46,7 +47,7 @@ export default function MediaServerWidget({
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
return (
<Stack className={classes.searchRoot} gap={0}>
<Stack className={searchClasses.searchRoot} gap={0}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
{failedIntegrations.length > 0 && (
<Group px="sm" pt="xs">
@@ -64,6 +65,7 @@ export default function MediaServerWidget({
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
isDense={displayMode !== "advanced" && (width < 340 || height < 150)}
isAdvanced={displayMode === "advanced"}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options}
@@ -81,7 +83,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
return (
<Tooltip label={t("action.search.label")}>
<ActionIcon
className={classes.searchButton}
className={searchClasses.searchButton}
variant="light"
size="sm"
aria-label={t("action.search.label")}
@@ -96,33 +98,23 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean;
isDense: boolean;
isAdvanced: boolean;
canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
return (
<Card
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status}`}
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${request.type} mediaRequests-list-item-${request.status} ${classes.card}`}
radius={board.itemRadius}
p="xs"
p={isDense ? 6 : "xs"}
withBorder
>
<Image
className="mediaRequests-list-item-background"
src={request.backdropImageUrl}
pos="absolute"
w="100%"
h="100%"
opacity={0.2}
top={0}
left={0}
alt=""
/>
<Group
className="mediaRequests-list-item-contents"
h="100%"
@@ -131,20 +123,55 @@ const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }:
wrap="nowrap"
gap={0}
>
<Group className="mediaRequests-list-item-left-side" h="100%" gap="md" wrap="nowrap" flex={1}>
<Group
className="mediaRequests-list-item-left-side"
h="100%"
gap={isDense ? "xs" : "md"}
wrap="nowrap"
flex={1}
miw={0}
>
{!isTiny && (
<Image
className="mediaRequests-list-item-poster"
src={request.posterImagePath}
h={40}
h={isDense ? 36 : 44}
w="auto"
radius={"md"}
radius="sm"
alt=""
style={{ flexShrink: 0 }}
/>
)}
<Stack gap={0} w="100%">
<Group justify="space-between" gap="xs" className="mediaRequests-list-item-top-group">
<Group gap="xs">
<Stack gap={2} w="100%" miw={0}>
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
href={request.href}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
fz={isTiny ? "xs" : "sm"}
fw={600}
title={request.name}
truncate="end"
style={{ minWidth: 0, flex: 1 }}
>
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
alwaysVisible={isTiny}
/>
) : (
<StatusBadge status={request.status} />
)}
</Group>
<Group justify="space-between" gap="xs" wrap="nowrap" className="mediaRequests-list-item-bottom-group">
<Group gap={4} wrap="nowrap" miw={0}>
<Text className="mediaRequests-list-item-media-year" size="xs">
{toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")}
</Text>
@@ -164,46 +191,26 @@ const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }:
</Badge>
)}
</Group>
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap">
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size="xs"
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
href={request.requestedBy?.link}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
fz="xs"
lineClamp={1}
style={{ wordBreak: "break-all" }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
</Group>
<Group gap="xs" justify="space-between" className="mediaRequests-list-item-bottom-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
href={request.href}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
fz={isTiny ? "xs" : "sm"}
fw={"bold"}
title={request.name}
lineClamp={1}
>
{request.name || "unknown"}
</Anchor>
{request.status === "pending" ? (
<DecisionButtons
requestId={request.id}
integrationId={request.integrationId}
canInteract={canInteract}
/>
) : (
<StatusBadge status={request.status} />
{!isTiny && (
<Group className="mediaRequests-list-item-request-user" gap={4} wrap="nowrap" miw={0}>
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={request.requestedBy?.avatar}
size={18}
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
href={request.requestedBy?.link}
c="dimmed"
target={options.linksTargetNewTab ? "_blank" : "_self"}
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
fz="xs"
truncate="end"
style={{ minWidth: 0, maxWidth: isAdvanced ? 180 : 100 }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
)}
</Group>
</Stack>
@@ -217,9 +224,10 @@ interface DecisionButtonsProps {
requestId: number;
integrationId: string;
canInteract: boolean;
alwaysVisible: boolean;
}
const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButtonsProps) => {
const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible }: DecisionButtonsProps) => {
const utils = clientApi.useUtils();
const {
mutate: mutateRequestAnswer,
@@ -239,13 +247,18 @@ const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButt
};
return (
<Group className="mediaRequests-list-item-pending-buttons" gap="sm" title={error?.message}>
<Group
className={`mediaRequests-list-item-pending-buttons ${classes.pendingActions} ${alwaysVisible ? classes.pendingActionsVisible : ""}`}
gap={4}
wrap="nowrap"
title={error?.message}
>
<Tooltip label={t("pending.approve")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
size="xs"
size="sm"
disabled={!canInteract || isPending}
aria-label={t("pending.approve")}
onClick={() => {
@@ -260,7 +273,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButt
className="mediaRequests-list-item-pending-button-decline"
variant="light"
color="red"
size="xs"
size="sm"
disabled={!canInteract || isPending}
aria-label={t("pending.decline")}
onClick={() => {
@@ -5,3 +5,25 @@
[data-mantine-color-scheme="dark"] .card {
background-color: var(--mantine-color-dark-7);
}
.userCard {
color: inherit;
text-decoration: none;
transition: background-color 120ms ease;
&:hover,
&:focus-visible {
background-color: var(--mantine-color-default-hover);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
@media (prefers-reduced-motion: reduce) {
.userCard {
transition: none;
}
}
@@ -97,6 +97,7 @@ export default function MediaServerWidget({
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
const showSectionTitles = displayMode === "advanced" || height >= 180;
const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
const visibleUsers = requestStats.users.slice(
0,
@@ -119,9 +120,11 @@ export default function MediaServerWidget({
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
{showSectionTitles && (
<Text className="mediaRequests-stats-stats-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.stats.main")}
</Text>
)}
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
{visibleStats.map((stat) => (
<Grid.Col
@@ -144,9 +147,11 @@ export default function MediaServerWidget({
</Grid>
</Stack>
<Stack gap={4} w="100%">
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
{showSectionTitles && (
<Text className="mediaRequests-stats-users-title" fw={600} ta="center" size={isTiny ? "xs" : "sm"}>
{t("titles.users.main")} ({t("titles.users.requests")})
</Text>
)}
<ScrollArea className="mediaRequests-stats-users-wrapper" flex={1} w="100%">
<Stack gap={4}>
{visibleUsers.map((user) => (
@@ -159,6 +164,7 @@ export default function MediaServerWidget({
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
classes.card,
classes.userCard,
)}
key={`${user.integration.id}:${user.id}`}
p="xs"
@@ -171,7 +177,7 @@ export default function MediaServerWidget({
gap="sm"
justify="space-between"
>
<Group gap={4}>
<Group gap={4} wrap="nowrap" miw={0}>
<Tooltip label={user.integration.name}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
@@ -180,7 +186,7 @@ export default function MediaServerWidget({
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
/>
</Tooltip>
<Text className="mediaRequests-stats-users-user-userName" size="sm">
<Text className="mediaRequests-stats-users-user-userName" size="sm" truncate="end">
{user.displayName}
</Text>
</Group>
@@ -0,0 +1,13 @@
.sessionRow {
cursor: pointer;
&:hover,
&:focus-visible {
background-color: var(--mantine-color-default-hover);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
@@ -13,6 +13,7 @@ import {
SimpleGrid,
Stack,
Text,
VisuallyHidden,
} from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
@@ -87,7 +88,7 @@ export default function MediaTranscodingWidget({
if (!transcodingData) return <WidgetEmptyState />;
const queuePageCount = totalQueuePages ?? 1;
const isTiny = !isAdvanced && width < 256;
const isTiny = !isAdvanced && (width < 280 || height < 140);
if (isAdvanced) {
return (
@@ -131,7 +132,9 @@ export default function MediaTranscodingWidget({
label: (
<Center style={{ gap: 4 }}>
<Icon size={12} />
{!isTiny && (
{isTiny ? (
<VisuallyHidden>{t(`tab.${value}`)}</VisuallyHidden>
) : (
<Text span size="xs">
{t(`tab.${value}`)}
</Text>
@@ -1,5 +1,5 @@
import type { MantineColor } from "@mantine/core";
import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core";
import { ActionIcon, Divider, Group, Indicator, Popover, RingProgress, Stack, Text } from "@mantine/core";
import { useColorScheme } from "@mantine/hooks";
import { IconHeartbeat } from "@tabler/icons-react";
@@ -21,13 +21,15 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
: "green";
return (
<HoverCard position="bottom" width={250} shadow="sm">
<HoverCard.Target>
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size={16} />
</Indicator>
</HoverCard.Target>
<HoverCard.Dropdown
<Popover position="bottom" width={250} shadow="sm" withArrow>
<Popover.Target>
<ActionIcon variant="subtle" size="sm" aria-label={t("title")}>
<Indicator color={textColor(indicatorColor, colorScheme)} size={6} display="flex">
<IconHeartbeat size={16} />
</Indicator>
</ActionIcon>
</Popover.Target>
<Popover.Dropdown
style={{
backgroundColor: "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-8))",
}}
@@ -70,8 +72,8 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) {
</Stack>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,6 +1,6 @@
"use client";
import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { IconCube, IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -16,14 +16,25 @@ export default function MinecraftServerStatusWidget({
height,
displayMode,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const { data: result, isPending } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n();
if (isPending) {
return (
<Flex align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{t("common.action.loading")}
</Text>
</Flex>
);
}
if (!result) return <WidgetEmptyState />;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 220 || height < 120);
const showServerIcon = isAdvanced || (!isDense && height >= 144);
const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
@@ -33,11 +44,11 @@ export default function MinecraftServerStatusWidget({
h="100%"
w="100%"
direction="column"
p={isAdvanced ? "lg" : "sm"}
p={isAdvanced ? "lg" : isDense ? "xs" : "sm"}
justify="center"
align="center"
>
<Group gap="xs" wrap="nowrap" align="center">
<Group gap="xs" wrap="nowrap" align="center" maw="100%">
<Tooltip
label={
data.online
@@ -45,8 +56,13 @@ export default function MinecraftServerStatusWidget({
: t("widget.minecraftServerStatus.status.offline")
}
>
<Box miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }}></Box>
<Box aria-hidden miw="md" h="md" bg={data.online ? "teal" : "red"} style={{ borderRadius: "100%" }} />
</Tooltip>
<VisuallyHidden>
{data.online
? t("widget.minecraftServerStatus.status.online")
: t("widget.minecraftServerStatus.status.offline")}
</VisuallyHidden>
<Text size={isAdvanced ? "xl" : "md"} fw="bold" truncate="end">
{title}
</Text>
@@ -62,6 +78,7 @@ export default function MinecraftServerStatusWidget({
{data.online && (
<>
{!options.isBedrockServer &&
showServerIcon &&
(data.icon ? (
<img
style={{ flex: 1, width: iconSize, maxHeight: iconSize, objectFit: "contain" }}
@@ -83,7 +100,7 @@ export default function MinecraftServerStatusWidget({
<Stack gap={4} w={isAdvanced ? "min(100%, 420px)" : "auto"} align="stretch">
<Group gap={5} c="dimmed" align="center" justify="center">
<IconUsersGroup size={isAdvanced ? "1.25rem" : "1rem"} />
<Text size={isAdvanced ? "lg" : "md"}>
<Text size={isAdvanced ? "lg" : isDense ? "sm" : "md"}>
{formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
</Text>
</Group>
@@ -1,13 +1,14 @@
"use client";
import { useMemo } from "react";
import { Box, Card, SimpleGrid } from "@mantine/core";
import { Box, Card, Center, SimpleGrid, Text } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import objectSupport from "dayjs/plugin/objectSupport";
import relativeTime from "dayjs/plugin/relativeTime";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
import { WifiVariant } from "./variants/wifi-variant";
@@ -22,10 +23,12 @@ export default function NetworkControllerNetworkStatusWidget({
integrationIds,
displayMode,
width,
height,
}: WidgetComponentProps<"networkControllerStatus">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const isAdvanced = displayMode === "advanced";
@@ -33,18 +36,39 @@ export default function NetworkControllerNetworkStatusWidget({
const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
const useHorizontalStats = !isAdvanced && height < 150 && width >= 200;
if (isPending || data.length === 0) {
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{isPending ? t("common.action.loading") : t("widget.networkControllerStatus.error.integrationsDisconnected")}
</Text>
</Center>
);
}
return (
<Box p={isAdvanced ? "md" : "sm"} h="100%">
<Box p={isAdvanced ? "md" : height < 120 ? "xs" : "sm"} h="100%">
<SimpleGrid cols={isAdvanced && width >= 560 ? 2 : 1} h="100%" spacing="sm">
{(isAdvanced || options.content === "wifi") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
<WifiVariant countGuests={countWifiGuests} countUsers={countWifiUsers} compact={!isAdvanced} />
<WifiVariant
countGuests={countWifiGuests}
countUsers={countWifiUsers}
compact={!isAdvanced}
horizontal={useHorizontalStats}
/>
</Card>
)}
{(isAdvanced || options.content === "wired") && (
<Card p={isAdvanced ? "md" : 0} withBorder={isAdvanced}>
<WiredVariant countGuests={countLanGuests} countUsers={countLanUsers} compact={!isAdvanced} />
<WiredVariant
countGuests={countLanGuests}
countUsers={countLanUsers}
compact={!isAdvanced}
horizontal={useHorizontalStats}
/>
</Card>
)}
</SimpleGrid>
@@ -1,4 +1,4 @@
import { Group, Stack, Text } from "@mantine/core";
import { Group, SimpleGrid, Text } from "@mantine/core";
import { IconWifi } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
@@ -9,10 +9,12 @@ export const WifiVariant = ({
countGuests,
countUsers,
compact = false,
horizontal = false,
}: {
countUsers: number;
countGuests: number;
compact?: boolean;
horizontal?: boolean;
}) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
@@ -23,10 +25,10 @@ export const WifiVariant = ({
{t("variants.wifi.name")}
</Text>
</Group>
<Stack gap={compact ? "sm" : "lg"}>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</Stack>
</SimpleGrid>
</>
);
};
@@ -1,4 +1,4 @@
import { Group, Stack, Text } from "@mantine/core";
import { Group, SimpleGrid, Text } from "@mantine/core";
import { IconNetwork } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
@@ -9,10 +9,12 @@ export const WiredVariant = ({
countGuests,
countUsers,
compact = false,
horizontal = false,
}: {
countUsers: number;
countGuests: number;
compact?: boolean;
horizontal?: boolean;
}) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
@@ -23,10 +25,10 @@ export const WiredVariant = ({
{t("variants.wired.name")}
</Text>
</Group>
<Stack gap={compact ? "sm" : "lg"}>
<SimpleGrid cols={horizontal ? 2 : 1} spacing={compact ? "sm" : "lg"}>
<StatRow label={t("users.label")} value={countUsers} compact={compact} />
<StatRow label={t("guests.label")} value={countGuests} compact={compact} />
</Stack>
</SimpleGrid>
</>
);
};
@@ -1,6 +1,6 @@
"use client";
import { Badge, Card, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -19,19 +19,31 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"networkControllerSummary">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const t = useI18n();
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && height < 160;
const columns = isAdvanced && width >= 720 ? 2 : 1;
if (isPending || summaries.length === 0) {
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")}
</Text>
</Center>
);
}
return (
<ScrollArea h="100%" p={isAdvanced ? "md" : "sm"}>
<ScrollArea h="100%" p={isAdvanced ? "md" : isDense ? "xs" : "sm"}>
<SimpleGrid cols={columns} spacing="sm">
{summaries.map(({ integration, summary }) => (
<Card key={integration.id} p={isAdvanced ? "md" : "xs"} withBorder={summaries.length > 1 || isAdvanced}>
@@ -46,9 +58,9 @@ export default function NetworkControllerSummaryWidget({
</Badge>
</Group>
)}
<List spacing="xs" center>
<List.Item icon={<StatusIcon status={summary.wanStatus} />}>WAN</List.Item>
<List.Item icon={<StatusIcon status={summary.www.status} />}>
<List spacing={isDense ? 2 : "xs"} center size={isDense ? "sm" : undefined}>
<List.Item icon={<StatusIcon status={summary.wanStatus} size={isDense ? 16 : 20} />}>WAN</List.Item>
<List.Item icon={<StatusIcon status={summary.www.status} size={isDense ? 16 : 20} />}>
<Text>
WWW
<Text c="dimmed" size="sm" ms="xs" span>
@@ -56,15 +68,15 @@ export default function NetworkControllerSummaryWidget({
</Text>
</Text>
</List.Item>
<List.Item icon={<StatusIcon status={summary.wifi.status} />}>
<List.Item icon={<StatusIcon status={summary.wifi.status} size={isDense ? 16 : 20} />}>
Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
</List.Item>
{isAdvanced && (
<List.Item icon={<StatusIcon status={summary.lan.status} />}>
<List.Item icon={<StatusIcon status={summary.lan.status} size={20} />}>
LAN · {summary.lan.users + summary.lan.guests}
</List.Item>
)}
<List.Item icon={<StatusIcon status={summary.vpn.status} />}>
<List.Item icon={<StatusIcon status={summary.vpn.status} size={isDense ? 16 : 20} />}>
<Text>
VPN
<Text c="dimmed" size="sm" ms="xs" span>
@@ -81,11 +93,13 @@ export default function NetworkControllerSummaryWidget({
);
}
const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => {
const StatusIcon = ({ status, size }: { status?: "enabled" | "disabled"; size: number }) => {
const mantineTheme = useMantineTheme();
if (status === "enabled") {
return <IconCircleCheckFilled size={20} color={mantineTheme.colors.green[6]} />;
return <IconCircleCheckFilled aria-label={status} size={size} color={mantineTheme.colors.green[6]} />;
}
if (status === "disabled") return <IconCircleXFilled size={20} color={mantineTheme.colors.red[6]} />;
return <IconCircleXFilled size={20} color={mantineTheme.colors.gray[6]} />;
if (status === "disabled") {
return <IconCircleXFilled aria-label={status} size={size} color={mantineTheme.colors.red[6]} />;
}
return <IconCircleXFilled aria-label="unknown" size={size} color={mantineTheme.colors.gray[6]} />;
};
@@ -70,3 +70,31 @@
.ProseMirror {
height: 100%;
}
.homarr-notebook-action {
opacity: 0;
transform: translateX(2px);
transition:
opacity 120ms ease,
transform 120ms ease;
}
.homarr-notebook:hover .homarr-notebook-action,
.homarr-notebook:focus-within .homarr-notebook-action,
.homarr-notebook-action[data-visible="true"] {
opacity: 1;
transform: none;
}
@media (any-pointer: coarse) {
.homarr-notebook-action {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.homarr-notebook-action {
transition: none;
}
}
+5 -1
View File
@@ -341,7 +341,7 @@ export function Notebook({
};
return (
<Box h="100%" onDoubleClick={handleDoubleClick}>
<Box className="homarr-notebook" h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor
p={0}
mt={0}
@@ -528,6 +528,8 @@ export function Notebook({
{canChange && (
<>
<ActionIcon
className="homarr-notebook-action"
data-visible={isEditing || displayMode === "advanced" || undefined}
title={isEditing ? t("common.action.save") : t("common.action.edit")}
aria-label={isEditing ? t("common.action.save") : t("common.action.edit")}
style={{
@@ -547,6 +549,8 @@ export function Notebook({
</ActionIcon>
{isEditing && (
<ActionIcon
className="homarr-notebook-action"
data-visible
title={t("common.action.cancel")}
aria-label={t("common.action.cancel")}
style={{
@@ -15,9 +15,10 @@ export default function NotificationsWidget({
options,
integrationIds,
width,
height,
displayMode,
}: WidgetComponentProps<"notifications">) {
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
const { data: notificationIntegrations = [], isPending } = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
@@ -43,6 +44,8 @@ export default function NotificationsWidget({
(integration): integration is typeof integration & { error: string } => Boolean(integration.error),
);
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 280 || height < 180);
const bodyLineClamp = isAdvanced ? 12 : height < 112 ? 1 : isDense ? 2 : 4;
const columns = isAdvanced && width >= 720 ? 2 : 1;
return (
@@ -59,7 +62,13 @@ export default function NotificationsWidget({
))}
</Group>
)}
{sortedNotifications.length > 0 ? (
{isPending ? (
<Flex justify="center" align="center" mih={96} p="sm">
<Text size="sm" c="dimmed" ta="center">
{t("common.action.loading")}
</Text>
</Flex>
) : sortedNotifications.length > 0 ? (
<SimpleGrid cols={columns} spacing={isAdvanced ? "md" : "xs"} verticalSpacing={isAdvanced ? "md" : "xs"}>
{sortedNotifications.map((notification) => (
<Card
@@ -70,32 +79,45 @@ export default function NotificationsWidget({
rel={notification.href ? "noopener noreferrer" : undefined}
radius={board.itemRadius}
w="100%"
p={isAdvanced ? "md" : "xs"}
style={{ color: "inherit", textDecoration: "none" }}
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
bg={isAdvanced ? undefined : "transparent"}
style={{
color: "inherit",
textDecoration: "none",
borderBottom: isAdvanced
? undefined
: "1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4))",
}}
>
<Flex gap="sm" align="flex-start" w="100%">
<Flex gap={isDense ? "xs" : "sm"} align="flex-start" w="100%">
{!options.hideLogos && notification.source?.iconUrl && (
<Avatar
src={notification.source.iconUrl}
alt={notification.source.name}
size="sm"
size={isDense ? "xs" : "sm"}
radius={board.itemRadius}
/>
)}
<Flex gap={isAdvanced ? "sm" : 6} direction="column" w="100%" miw={0}>
<Flex gap={isAdvanced ? "sm" : isDense ? 4 : 6} direction="column" w="100%" miw={0}>
{notification.title && (
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
<Text fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={isDense ? 1 : 2}>
{notification.title}
</Text>
)}
<Text c="dimmed" size="sm" lineClamp={isAdvanced ? 12 : 4} style={{ whiteSpace: "pre-line" }}>
<Text
c="dimmed"
size={isDense ? "xs" : "sm"}
lineClamp={bodyLineClamp}
style={{ whiteSpace: "pre-line" }}
>
{notification.body}
</Text>
<InfoDisplay
date={notification.time}
source={isAdvanced ? (notification.source?.name ?? notification.integrationName) : undefined}
dense={isDense}
/>
</Flex>
</Flex>
@@ -103,22 +125,24 @@ export default function NotificationsWidget({
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
{t("widget.notifications.noItems")}
</Text>
<Flex justify="center" align="center" mih={96} p="sm">
<Text size="sm" c="dimmed" ta="center">
{t("widget.notifications.noItems")}
</Text>
</Flex>
)}
</Stack>
</ScrollArea>
);
}
const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => {
const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; dense: boolean }) => {
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
return (
<Group gap={5} align="center" wrap="nowrap">
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
<Text size="sm" c="dimmed">
<IconClock aria-hidden size={dense ? 12 : "1rem"} color="var(--mantine-color-dimmed)" />
<Text size={dense ? "xs" : "sm"} c="dimmed">
{timeAgo}
</Text>
{source && <Text c="dimmed">•</Text>}
@@ -2,7 +2,7 @@
import { Fragment } from "react";
import type { CSSProperties } from "react";
import { RingProgress, Text } from "@mantine/core";
import { Center, RingProgress, Text } from "@mantine/core";
import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -78,10 +78,20 @@ export default function PaperlessNgxWidget({
displayMode = "compact",
}: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
const tCommon = useScopedI18n("common");
const { data: stats, isPending } = clientApi.widget.paperlessNgx.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
if (isPending) {
return (
<Center h="100%">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Center>
);
}
if (!stats) return <WidgetEmptyState />;
const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal;
@@ -7,20 +7,41 @@
}
.releasesRepositoryHeader {
width: 100%;
border: 0;
color: inherit;
font: inherit;
text-align: start;
user-select: none;
cursor: pointer;
&:hover,
&.active,
&:focus-visible,
&:has(~ .releasesRepositoryDetails:hover) {
background-color: var(--mantine-color-secondaryColor-light);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.releasesRepositoryDetails {
width: 100%;
border: 0;
color: inherit;
font: inherit;
text-align: start;
background-color: var(--mantine-color-default-hover);
user-select: none;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.releasesRepositoryExpanded {
+275 -253
View File
@@ -1,7 +1,18 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Button, Divider, Group, ScrollArea, SimpleGrid, Stack, Text, Title, Tooltip } from "@mantine/core";
import {
Button,
Divider,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Title,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconArchive,
@@ -204,105 +215,111 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
: undefined
}
>
<Group
<UnstyledButton
className={combineClasses("releases-repository-header", classes.releasesRepositoryHeader, {
[classes.active ?? ""]: isActive,
})}
p="xs"
onClick={() => toggleExpandedDisplay(repository)}
aria-expanded={isActive}
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Group p="xs">
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? repository.providerMetadata?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Group
className="releases-repository-header-nameVersion-wrapper"
gap={5}
justify="space-between"
miw={0}
style={{ flex: 1 }}
>
{!options.showOnlyIcon && (
<Text className="releases-repository-header-name" size="xs">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{repository.name || repository.identifier}
</Text>
)}
<Group
className="releases-repository-header-nameVersion-wrapper"
gap={5}
justify="space-between"
miw={0}
style={{ flex: 1 }}
>
{!options.showOnlyIcon && (
<Text className="releases-repository-header-name" size="xs">
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
{repository.name || repository.identifier}
</Text>
)}
<Tooltip
className="releases-repository-header-version-tooltip"
withArrow
arrowSize={5}
label={repository.latestRelease}
events={{ hover: repository.latestRelease !== undefined, focus: false, touch: false }}
<Tooltip
className="releases-repository-header-version-tooltip"
withArrow
arrowSize={5}
label={repository.latestRelease}
events={{ hover: repository.latestRelease !== undefined, focus: true, touch: false }}
>
<Text
className="releases-repository-header-version"
size="xs"
fw={700}
truncate="end"
c={hasError ? "red" : "text"}
style={{ flexShrink: 1 }}
>
{hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
</Text>
</Tooltip>
</Group>
<Group
className="releases-repository-header-releaseDate-wrapper"
gap={5}
style={{ flex: "0 0 auto" }}
>
<Text
className="releases-repository-header-version"
className="releases-repository-header-releaseDate"
size="xs"
fw={700}
truncate="end"
c={hasError ? "red" : "text"}
style={{ flexShrink: 1 }}
c={
repository.viewed
? "green"
: repository.isNewRelease
? "primaryColor"
: repository.isStaleRelease
? "secondaryColor"
: "dimmed"
}
>
{hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
{repository.latestReleaseAt &&
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "long",
})}
</Text>
</Tooltip>
</Group>
<Group className="releases-repository-header-releaseDate-wrapper" gap={5} style={{ flex: "0 0 auto" }}>
<Text
className="releases-repository-header-releaseDate"
size="xs"
c={
repository.viewed
? "green"
: repository.isNewRelease
? "primaryColor"
: repository.isStaleRelease
? "secondaryColor"
: "dimmed"
}
>
{repository.latestReleaseAt &&
!hasError &&
formatter.relativeTime(repository.latestReleaseAt, {
now,
style: "long",
})}
</Text>
{hasError ? (
<IconTriangleFilled
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
size={10}
color="var(--mantine-color-red-filled)"
/>
) : repository.viewed ? (
<IconCheck
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
size={10}
color="green"
/>
) : (
(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
{hasError ? (
<IconTriangleFilled
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-error"
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
color="var(--mantine-color-red-filled)"
/>
)
)}
) : repository.viewed ? (
<IconCheck
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-confirmed"
size={10}
color="green"
/>
) : (
(repository.isNewRelease || repository.isStaleRelease) && (
<IconCircleFilled
className="releases-repository-header-releaseDate-icon releases-repository-header-releaseDate-marker"
size={10}
color={
repository.isNewRelease
? "var(--mantine-color-primaryColor-filled)"
: "var(--mantine-color-secondaryColor-filled)"
}
/>
)
)}
</Group>
</Group>
</Group>
</UnstyledButton>
{(options.showDetails || isAdvanced) && (
<DetailsDisplay repository={repository} toggleExpandedDisplay={toggleExpandedDisplay} />
)}
@@ -334,198 +351,203 @@ const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayPro
return (
<>
<Divider className="releases-repository-details-divider" onClick={() => toggleExpandedDisplay(repository)} />
<Group
<Divider className="releases-repository-details-divider" />
<UnstyledButton
className={combineClasses("releases-repository-details", classes.releasesRepositoryDetails)}
justify="space-between"
p={5}
onClick={() => toggleExpandedDisplay(repository)}
>
<Group className="releases-repository-details-icon-wrapper">
<Tooltip
className={combineClasses(
"releases-repository-details-icon-tooltip",
"releases-repository-details-icon-preRelease-tooltip",
)}
label={t("pre-release")}
withArrow
arrowSize={5}
>
<IconProgressCheck
<Group justify="space-between" p={5}>
<Group className="releases-repository-details-icon-wrapper">
<Tooltip
className={combineClasses(
"releases-repository-details-icon",
"releases-repository-details-icon-preRelease",
"releases-repository-details-icon-tooltip",
"releases-repository-details-icon-preRelease-tooltip",
)}
size={13}
color={
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
}
/>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-icon-tooltip",
"releases-repository-details-icon-archived-tooltip",
)}
label={t("archived")}
withArrow
arrowSize={5}
>
<IconArchive
className={combineClasses(
"releases-repository-details-icon",
"releases-repository-details-icon-archived",
)}
size={13}
color={repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-icon-tooltip",
"releases-repository-details-icon-forked-tooltip",
)}
label={t("forked")}
withArrow
arrowSize={5}
>
<IconGitFork
className={combineClasses("releases-repository-details-icon", "releases-repository-details-icon-forked")}
size={13}
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
</Tooltip>
</Group>
<Group className="releases-repository-details-stats">
<Tooltip
className={combineClasses(
"releases-repository-details-stats-tooltip",
"releases-repository-details-stats-stars-tooltip",
)}
label={t("starsCount")}
withArrow
arrowSize={5}
>
<Group
className={combineClasses(
"releases-repository-details-stats-wrapper",
"releases-repository-details-stats-stars-wrapper",
)}
gap={5}
label={t("pre-release")}
withArrow
arrowSize={5}
>
<IconStar
<IconProgressCheck
className={combineClasses(
"releases-repository-details-stats-icon",
"releases-repository-details-stats-stars-icon",
"releases-repository-details-icon",
"releases-repository-details-icon-preRelease",
)}
size={12}
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
size={13}
color={
repository.isPreRelease ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
}
/>
<Text
className={combineClasses(
"releases-repository-details-stats-text",
"releases-repository-details-stats-stars-text",
)}
size="xs"
c={!repository.starsCount ? "dimmed" : ""}
>
{!repository.starsCount
? "-"
: formatter.number(repository.starsCount, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-stats-tooltip",
"releases-repository-details-stats-forks-tooltip",
)}
label={t("forksCount")}
withArrow
arrowSize={5}
>
<Group
<Tooltip
className={combineClasses(
"releases-repository-details-stats-wrapper",
"releases-repository-details-stats-forks-wrapper",
"releases-repository-details-icon-tooltip",
"releases-repository-details-icon-archived-tooltip",
)}
gap={5}
label={t("archived")}
withArrow
arrowSize={5}
>
<IconArchive
className={combineClasses(
"releases-repository-details-icon",
"releases-repository-details-icon-archived",
)}
size={13}
color={
repository.isArchived ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"
}
/>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-icon-tooltip",
"releases-repository-details-icon-forked-tooltip",
)}
label={t("forked")}
withArrow
arrowSize={5}
>
<IconGitFork
className={combineClasses(
"releases-repository-details-stats-icon",
"releases-repository-details-stats-forks-icon",
"releases-repository-details-icon",
"releases-repository-details-icon-forked",
)}
size={12}
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
size={13}
color={repository.isFork ? "var(--mantine-color-secondaryColor-text)" : "var(--mantine-color-dimmed)"}
/>
<Text
className={combineClasses(
"releases-repository-details-stats-text",
"releases-repository-details-stats-forks-text",
)}
size="xs"
c={!repository.forksCount ? "dimmed" : ""}
>
{!repository.forksCount
? "-"
: formatter.number(repository.forksCount, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-stats-tooltip",
"releases-repository-details-stats-issues-tooltip",
)}
label={t("issuesCount")}
withArrow
arrowSize={5}
>
<Group
</Tooltip>
</Group>
<Group className="releases-repository-details-stats">
<Tooltip
className={combineClasses(
"releases-repository-details-stats-wrapper",
"releases-repository-details-stats-issues-wrapper",
"releases-repository-details-stats-tooltip",
"releases-repository-details-stats-stars-tooltip",
)}
gap={5}
label={t("starsCount")}
withArrow
arrowSize={5}
>
<IconCircleDot
<Group
className={combineClasses(
"releases-repository-details-stats-icon",
"releases-repository-details-stats-issues-icon",
"releases-repository-details-stats-wrapper",
"releases-repository-details-stats-stars-wrapper",
)}
size={12}
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text
className={combineClasses(
"releases-repository-details-stats-text",
"releases-repository-details-stats-issues-text",
)}
size="xs"
c={!repository.openIssues ? "dimmed" : ""}
gap={5}
>
{!repository.openIssues
? "-"
: formatter.number(repository.openIssues, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
<IconStar
className={combineClasses(
"releases-repository-details-stats-icon",
"releases-repository-details-stats-stars-icon",
)}
size={12}
color={!repository.starsCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text
className={combineClasses(
"releases-repository-details-stats-text",
"releases-repository-details-stats-stars-text",
)}
size="xs"
c={!repository.starsCount ? "dimmed" : ""}
>
{!repository.starsCount
? "-"
: formatter.number(repository.starsCount, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-stats-tooltip",
"releases-repository-details-stats-forks-tooltip",
)}
label={t("forksCount")}
withArrow
arrowSize={5}
>
<Group
className={combineClasses(
"releases-repository-details-stats-wrapper",
"releases-repository-details-stats-forks-wrapper",
)}
gap={5}
>
<IconGitFork
className={combineClasses(
"releases-repository-details-stats-icon",
"releases-repository-details-stats-forks-icon",
)}
size={12}
color={!repository.forksCount ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text
className={combineClasses(
"releases-repository-details-stats-text",
"releases-repository-details-stats-forks-text",
)}
size="xs"
c={!repository.forksCount ? "dimmed" : ""}
>
{!repository.forksCount
? "-"
: formatter.number(repository.forksCount, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
<Tooltip
className={combineClasses(
"releases-repository-details-stats-tooltip",
"releases-repository-details-stats-issues-tooltip",
)}
label={t("issuesCount")}
withArrow
arrowSize={5}
>
<Group
className={combineClasses(
"releases-repository-details-stats-wrapper",
"releases-repository-details-stats-issues-wrapper",
)}
gap={5}
>
<IconCircleDot
className={combineClasses(
"releases-repository-details-stats-icon",
"releases-repository-details-stats-issues-icon",
)}
size={12}
color={!repository.openIssues ? "var(--mantine-color-dimmed)" : "var(--mantine-color-text)"}
/>
<Text
className={combineClasses(
"releases-repository-details-stats-text",
"releases-repository-details-stats-issues-text",
)}
size="xs"
c={!repository.openIssues ? "dimmed" : ""}
>
{!repository.openIssues
? "-"
: formatter.number(repository.openIssues, {
notation: "compact",
maximumFractionDigits: 1,
})}
</Text>
</Group>
</Tooltip>
</Group>
</Group>
</Group>
</UnstyledButton>
</>
);
};
@@ -1,11 +1,26 @@
.backgroundImage {
position: absolute;
width: 100%;
height: 100%;
filter: blur(5px);
transform: scaleX(-1.05) scaleZ(-1.05);
opacity: 0.25;
top: 0;
left: 0;
object-fit: cover;
.entry {
min-width: 0;
color: inherit;
text-decoration: none;
transition: background-color 120ms ease;
&:hover,
&:focus-visible {
background-color: var(--mantine-color-default-hover);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
.poster {
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
.entry {
transition: none;
}
}
+17 -21
View File
@@ -33,7 +33,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
};
};
export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
const {
entries: feedEntries,
failedFeedCount,
@@ -54,11 +54,13 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
const languageDir = options.enableRtl ? "RTL" : "LTR";
const isAdvanced = displayMode === "advanced";
const isNarrow = !isAdvanced && width < 128 * 3;
const isDense = !isAdvanced && (width < 420 || height < 180);
const isTiny = !isAdvanced && (width < 260 || height < 110);
const columns = isAdvanced && width >= 720 ? 2 : 1;
const descriptionLines = isAdvanced ? Math.max(options.textLinesClamp, 8) : isDense ? 1 : options.textLinesClamp;
return (
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : "xs"}>
<ScrollArea className="scroll-area-w100" w="100%" h="100%" p={isAdvanced ? "md" : isTiny ? 4 : "xs"}>
{warning && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs" mb="xs">
<output>{warning}</output>
@@ -68,25 +70,24 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
{feedEntries.map((feedEntry) => (
<Card
key={feedEntry.id}
className={classes.entry}
component={"a"}
href={getSafeExternalUrl(feedEntry.link, feedEntry.feedUrl)}
radius={board.itemRadius}
target="_blank"
rel="noopener noreferrer"
w="100%"
p={isAdvanced ? "md" : "xs"}
p={isAdvanced ? "md" : isDense ? 6 : "xs"}
title={feedEntry.title}
>
{feedEntry.enclosure !== undefined && (
<Image className={classes.backgroundImage} src={feedEntry.enclosure} alt="backdrop" />
)}
<Group wrap="nowrap" align="flex-start">
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
<Image
className={classes.poster}
src={feedEntry.enclosure}
alt={feedEntry.title}
w={isAdvanced ? 180 : 112}
h={isAdvanced ? 120 : 112}
alt=""
w={isAdvanced ? 180 : isDense ? 64 : 96}
h={isAdvanced ? 120 : isDense ? 64 : 96}
radius="sm"
fit="cover"
/>
@@ -96,19 +97,14 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent
<Text dir={languageDir} fz={isAdvanced ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
{feedEntry.title}
</Text>
{!options.hideDescription && feedEntry.description && (
<Text
dir={languageDir}
c="dimmed"
size="sm"
lineClamp={isAdvanced ? Math.max(options.textLinesClamp, 8) : options.textLinesClamp}
>
{!options.hideDescription && feedEntry.description && !isTiny && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={isAdvanced ? getHostname(feedEntry.feedUrl) : undefined}
source={isAdvanced || !isDense ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
/>
</Flex>
@@ -83,12 +83,18 @@ export default function SmartHomeEntityStateWidget({
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
{ key: "icon" as const, value: entity?.attributes.icon },
].filter(({ value }) => typeof value === "string" && value.length > 0);
const displayName =
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name
: options.displayName;
const state = entity?.state ?? "—";
return (
<UnstyledButton
mod={{ "entity-state": entity?.state, "entity-id": options.entityId }}
onClick={handleClick}
disabled={!isActionable}
aria-label={`${displayName}: ${state}${attribute}`}
aria-description={error?.message}
w="100%"
h="100%"
@@ -101,15 +107,13 @@ export default function SmartHomeEntityStateWidget({
>
<Center h="100%" w="100%">
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%">
<Text ta="center" fw="bold" size={isTiny ? "sm" : "lg"}>
{displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name
: options.displayName}
</Text>
<Text ta="center" size={isTiny ? "xs" : "lg"}>
{entity?.state ?? "—"}
<Text ta="center" fw={700} size={isTiny ? "lg" : "2xl"} lh={1.1} lineClamp={1} maw="100%">
{state}
{attribute}
</Text>
<Text ta="center" c="dimmed" fw={500} size={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
{displayName}
</Text>
{displayMode === "advanced" && entity && (
<>
<Group justify="center" gap={4}>
@@ -131,7 +135,7 @@ export default function SmartHomeEntityStateWidget({
)}
{error && (
<Tooltip label={error.message} multiline>
<Text size="xs" c="red" lineClamp={2}>
<Text size="xs" c="red" lineClamp={2} tabIndex={0}>
{t("widget.smartHome-entityState.error.toggleFailed")}
</Text>
</Tooltip>
@@ -87,7 +87,7 @@ export default function SmartHomeTriggerAutomationWidget({
return (
<Box pos="relative" w="100%" h="100%">
<VisuallyHidden role="status">
<VisuallyHidden component="output">
{isPending
? t("widget.smartHome-executeAutomation.status.running")
: isShowSuccess
@@ -100,6 +100,7 @@ export default function SmartHomeTriggerAutomationWidget({
<UnstyledButton
onClick={handleClick}
disabled={!integrationId || !canInteract || isPending}
aria-label={t("widget.smartHome-executeAutomation.spotlightAction.run", { name: options.displayName })}
style={{
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
pointerEvents: isEditMode ? "none" : undefined,
@@ -130,9 +131,11 @@ export default function SmartHomeTriggerAutomationWidget({
</Overlay>
)}
<Center w="100%" h="100%">
<Stack align="center" gap="md">
<IconAutomation size={isTiny ? 16 : undefined} />
<Text ta="center" fw="bold" fz={isTiny ? "xs" : undefined}>
<Stack align="center" gap={isTiny ? 6 : "md"} p="xs" maw="100%">
<ThemeIcon variant="light" size={isTiny ? "md" : "xl"} radius="xl">
<IconAutomation size={isTiny ? 14 : 24} />
</ThemeIcon>
<Text ta="center" fw={600} fz={isTiny ? "xs" : "sm"} lineClamp={2} maw="100%">
{options.displayName}
</Text>
{displayMode === "advanced" && (
@@ -9,7 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { combineSpeedtestDashboards } from "./helpers";
import { combineSpeedtestDashboards, getCompactSections } from "./helpers";
import { LatestResultSection } from "./latest-result";
import { RecentResultsSection } from "./recent-results";
@@ -48,6 +48,11 @@ export default function SpeedtestTrackerWidget({
const averages = options.showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={displayMode === "advanced" ? advancedSectionWidth : width} />
);
const compactSections = getCompactSections(height, {
latest: Boolean(latest),
chart: hasChart,
averages: Boolean(averages),
});
if (displayMode === "advanced") {
return (
@@ -85,13 +90,13 @@ export default function SpeedtestTrackerWidget({
return (
<Stack h="100%" gap="sm" p="xs" style={{ overflow: "hidden" }}>
{hasStatSection && (
{(compactSections.latest || compactSections.averages) && (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
{latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{averages && height >= 170 && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
{compactSections.latest && <div style={{ flex: 1, minHeight: 0 }}>{latest}</div>}
{compactSections.averages && <div style={{ flex: 1, minHeight: 0 }}>{averages}</div>}
</Stack>
)}
{hasChart && (
{compactSections.chart && (
<div style={{ flex: 2, minHeight: 0 }}>
<RecentResultsSection results={recentFiltered} showPingGraph={options.showPingGraph} />
</div>
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
import { combineSpeedtestDashboards, mergeStats } from "./helpers";
import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers";
const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
ping: { avg: average, min: average, max: average },
@@ -26,3 +26,16 @@ describe("speedtest dashboard aggregation", () => {
expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2);
});
});
describe("getCompactSections", () => {
const available = { latest: true, chart: true, averages: true };
it("prioritizes the latest result in a short widget", () => {
expect(getCompactSections(180, available)).toEqual({ latest: true, chart: false, averages: false });
});
it("reveals history before averages as height grows", () => {
expect(getCompactSections(300, available)).toEqual({ latest: true, chart: true, averages: false });
expect(getCompactSections(400, available)).toEqual({ latest: true, chart: true, averages: true });
});
});
@@ -75,3 +75,21 @@ export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): strin
if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits);
return formatBitsPerSec(band.avg * 8);
};
type CompactSection = "latest" | "chart" | "averages";
export const getCompactSections = (
height: number,
available: Record<CompactSection, boolean>,
): Record<CompactSection, boolean> => {
const budget = height < 240 ? 1 : height < 360 ? 2 : 3;
const visible = new Set(
(["latest", "chart", "averages"] as const).filter((section) => available[section]).slice(0, budget),
);
return {
latest: visible.has("latest"),
chart: visible.has("chart"),
averages: visible.has("averages"),
};
};
@@ -26,7 +26,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest
<Stack gap={6} h="100%">
<Group justify="space-between" align="center" wrap="nowrap">
<SectionLabel>{t("latestResult")}</SectionLabel>
<Text size="xs" c="dimmed">
<Text size="xs" c="dimmed" truncate title={timestamp} style={{ minWidth: 0 }}>
{timestamp}
</Text>
</Group>
@@ -3,7 +3,7 @@ import { Text } from "@mantine/core";
export function SectionLabel({ children }: { children: ReactNode }) {
return (
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.05em" }}>
<Text size="xs" fw={600} c="dimmed">
{children}
</Text>
);
@@ -1,6 +1,6 @@
"use client";
import { Card, Flex, Text } from "@mantine/core";
import { Box, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -21,34 +21,37 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false
const hideLabel = height > 0 && height <= 38;
return (
<Card
ref={ref}
p={compact ? "xs" : "sm"}
radius={board.itemRadius}
bg={`var(--mantine-color-${color}-light)`}
h="100%"
style={{ flex: 1 }}
>
<Flex
<Tooltip label={`${label}: ${value}`} withArrow>
<Box
ref={ref}
p={compact ? "xs" : "sm"}
bg={`var(--mantine-color-${color}-light)`}
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
style={{ flex: 1, borderRadius: `var(--mantine-radius-${board.itemRadius})`, minWidth: 0 }}
>
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1}>
{value}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3}>
{label}
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
style={{ minWidth: 0 }}
>
<Icon size={compact ? 16 : 20} color={`var(--mantine-color-${color}-5)`} style={{ flexShrink: 0 }} />
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0} style={{ minWidth: 0 }}>
<Text size={compact ? "sm" : "md"} fw={700} ta="center" lh={1.1} truncate w="100%">
{value}
</Text>
)}
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" lh={1.3} truncate w="100%">
{label}
</Text>
)}
{hideLabel && <VisuallyHidden>{label}</VisuallyHidden>}
</Flex>
</Flex>
</Flex>
</Card>
</Box>
</Tooltip>
);
}
+36 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getStockSummary } from "./component";
import { getStockLayout, getStockSummary } from "./component";
describe("stock summary", () => {
it("returns no summary for an empty series", () => {
@@ -16,3 +16,38 @@ describe("stock summary", () => {
});
});
});
describe("stock layout", () => {
it("keeps compact widgets focused on symbol and price", () => {
expect(getStockLayout(320, 100, false)).toMatchObject({
showName: false,
showChange: false,
showRange: false,
priceOrder: 2,
});
});
it("reveals secondary data only when both axes have room", () => {
expect(getStockLayout(320, 160, false)).toMatchObject({
showName: false,
showChange: true,
showRange: true,
});
expect(getStockLayout(320, 220, false)).toMatchObject({
showName: true,
showChange: true,
showRange: true,
graphHeight: "68%",
});
});
it("always exposes the complete advanced summary", () => {
expect(getStockLayout(120, 80, true)).toEqual({
showName: true,
showChange: true,
showRange: true,
graphHeight: "75%",
priceOrder: 1,
});
});
});
+34 -5
View File
@@ -30,6 +30,34 @@ export interface StockSummary {
graphValues: number[];
}
export interface StockLayout {
showName: boolean;
showChange: boolean;
showRange: boolean;
graphHeight: "48%" | "68%" | "75%";
priceOrder: 1 | 2;
}
export function getStockLayout(width: number, height: number, isAdvanced: boolean): StockLayout {
if (isAdvanced) {
return {
showName: true,
showChange: true,
showRange: true,
graphHeight: "75%",
priceOrder: 1,
};
}
return {
showName: width >= 300 && height >= 190,
showChange: width >= 260 && height >= 130,
showRange: width >= 220 && height >= 130,
graphHeight: height >= 220 ? "68%" : "48%",
priceOrder: width >= 280 && height >= 120 ? 1 : 2,
};
}
export function getStockSummary(priceHistory: number[], previousClose: number): StockSummary | null {
const currentPrice = priceHistory.at(-1);
if (currentPrice === undefined) return null;
@@ -57,6 +85,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage);
const stockGraphValues = summary.graphValues;
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
const layout = getStockLayout(width, height, displayMode === "advanced");
return (
<Flex h="100%" w="100%">
@@ -64,7 +93,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
pos="absolute"
bottom={10}
w="100%"
h={displayMode === "advanced" || height > 280 ? "75%" : "50%"}
h={layout.graphHeight}
data={stockGraphValues}
curveType="linear"
color={trendColor}
@@ -81,18 +110,18 @@ export default function StockPriceWidget({ options, width, height, displayMode }
)}
{data.symbol}
</Text>
{(displayMode === "advanced" || (width > 280 && height > 280)) && (
{layout.showName && (
<Text size="md" lh="1">
{data.shortName}
</Text>
)}
</Stack>
<Title pos="absolute" bottom={10} right={10} order={width > 280 ? 1 : 2} fw={700}>
<Title pos="absolute" bottom={10} right={10} order={layout.priceOrder} fw={700}>
{new Intl.NumberFormat().format(round(summary.currentPrice))}
</Title>
{(displayMode === "advanced" || width > 280) && (
{layout.showChange && (
<Text pos="absolute" top={10} right={10} size="xl" fw={700}>
{new Intl.NumberFormat().format(stockValuesChange)}
{stockValuesChangePercentage === null
@@ -101,7 +130,7 @@ export default function StockPriceWidget({ options, width, height, displayMode }
</Text>
)}
{(displayMode === "advanced" || width > 280) && (
{layout.showRange && (
<Text pos="absolute" bottom={10} left={10} fw={700}>
{t(`option.timeRange.option.${options.timeRange}.label`)}
</Text>
+26 -11
View File
@@ -58,6 +58,7 @@ interface SystemDiskCardProps {
integrationName?: string;
secondaryText?: string;
isAdvanced: boolean;
showTemperature: boolean;
}
const SystemDiskCard = ({
@@ -70,6 +71,7 @@ const SystemDiskCard = ({
integrationName,
secondaryText,
isAdvanced,
showTemperature,
}: SystemDiskCardProps) => {
const board = useRequiredBoard();
const scheme = useMantineColorScheme();
@@ -95,11 +97,18 @@ const SystemDiskCard = ({
}, [displayText, healthy]);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature;
const tooltipLabel = [
healthy ? displayText : `${displayText} (${unhealthyLabel})`,
hasHiddenTemperature ? `${temperature}°C` : null,
]
.filter(Boolean)
.join(" · ");
return (
<Tooltip
label={healthy ? displayText : `${displayText} (${unhealthyLabel})`}
disabled={valueFits || isAdvanced}
label={tooltipLabel}
disabled={(valueFits && !hasHiddenTemperature) || isAdvanced}
position="top"
withinPortal
>
@@ -109,9 +118,9 @@ const SystemDiskCard = ({
bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"}
style={{ overflow: "hidden", position: "relative" }}
>
<Group justify="space-between" style={{ zIndex: 1 }}>
<div>
<Text fw={700} size={isAdvanced ? "sm" : undefined} truncate="end">
<Group justify="space-between" wrap="nowrap" style={{ zIndex: 1, minWidth: 0 }}>
<div style={{ minWidth: 0, flex: 1 }}>
<Text fw={700} size="sm" truncate="end">
{deviceName}
</Text>
{integrationName && (
@@ -119,22 +128,23 @@ const SystemDiskCard = ({
{integrationName}
</Text>
)}
<p ref={valueRef} style={{ margin: 0, visibility: valueFits ? "visible" : "hidden" }}>
<Text ref={valueRef} size="sm" style={{ visibility: valueFits ? "visible" : "hidden" }}>
<span>{displayText}</span>
{!healthy && <span style={{ marginLeft: 5 }}>{unhealthyLabel}</span>}
</p>
</Text>
{isAdvanced && secondaryText && (
<Text size="xs" c="dimmed">
{secondaryText}
</Text>
)}
</div>
<div>
{temperature !== null && temperature !== undefined ? <p style={{ margin: 0 }}>{temperature}°C</p> : null}
<div style={{ flexShrink: 0 }}>
{showTemperature && temperature !== null && temperature !== undefined ? (
<Text size="sm">{temperature}°C</Text>
) : null}
</div>
</Group>
<Box
bg={healthy ? "green" : "red"}
style={{
position: "absolute",
top: 0,
@@ -143,6 +153,7 @@ const SystemDiskCard = ({
height: "100%",
zIndex: 0,
display: showBackgroundBar ? "block" : "none",
backgroundColor: healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)",
}}
></Box>
</Card>
@@ -154,6 +165,7 @@ export default function SystemResources({
integrationIds,
options,
width,
height,
displayMode: surfaceMode,
}: WidgetComponentProps<"systemDisks">) {
const queryInput = { integrationIds };
@@ -182,7 +194,9 @@ export default function SystemResources({
if (disks.length === 0) return <WidgetEmptyState />;
const isAdvanced = surfaceMode === "advanced";
const columns = isAdvanced ? Math.max(1, Math.min(disks.length, Math.floor(width / 320))) : 1;
const minimumCardWidth = isAdvanced ? 320 : 260;
const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth)));
const cellWidth = width / columns;
return (
<ScrollArea h="100%">
@@ -199,6 +213,7 @@ export default function SystemResources({
integrationName={isAdvanced || data.length > 1 ? integrationName : undefined}
secondaryText={getAbsoluteText(item)}
isAdvanced={isAdvanced}
showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)}
/>
))}
</SimpleGrid>
@@ -83,6 +83,13 @@ export const CommonChart = ({
)}
</Group>
)}
{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}>
@@ -92,6 +99,11 @@ export const CommonChart = ({
{title}
</Text>
)}
{lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue}
</Text>
)}
</Stack>
</Center>
) : (
@@ -22,7 +22,7 @@ export const SystemResourceMemoryChart = ({
const t = useScopedI18n("widget.systemResources.card");
const percentageUsed =
memoryUsageOverTime.length > 0
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
: undefined;
@@ -45,7 +45,8 @@ export const SystemResourceMemoryChart = ({
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
{memory.used} / {memory.available} (
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
</Text>
</Paper>
);
@@ -23,6 +23,7 @@ export const NetworkTrafficChart = ({
const max = Math.max(...usageOverTime);
const upperBound = max + max * 0.2;
const latest = usageOverTime.at(-1) ?? 0;
return (
<CommonChart
@@ -32,7 +33,7 @@ export const NetworkTrafficChart = ({
title={isUp ? t("up") : t("down")}
icon={isUp ? IconArrowUp : IconArrowDown}
yAxisProps={{ domain: [0, upperBound] }}
lastValue={formatByteRate(Math.round(max))}
lastValue={formatByteRate(Math.round(latest))}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
tooltipProps={{
@@ -1,11 +1,16 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
import { SystemResourceCPUChart } from "./chart/cpu-chart";
import { SystemResourceGPUChart } from "./chart/gpu-chart";
@@ -14,6 +19,9 @@ import { NetworkTrafficChart } from "./chart/network-traffic";
const COMPACT_HISTORY_SIZE = 15;
const ADVANCED_HISTORY_SIZE = 60;
const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const;
type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number];
type ChartItem = {
cpu: number;
@@ -40,19 +48,59 @@ export const toChartItem = (healthInfo: {
export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
[...history, item].slice(-limit);
export const getNetworkHistory = (items: readonly ChartItem[]) =>
items.flatMap((item) => (item.network === null ? [] : [item.network]));
export const getCompactChartBudget = (height: number): number => {
if (height < 140) return 1;
if (height < 260) return 2;
return 4;
};
export const getVisibleSystemCharts = ({
configuredCharts,
hasGpu,
hasNetwork,
height,
isAdvanced,
}: {
configuredCharts: readonly SystemChart[];
hasGpu: boolean;
hasNetwork: boolean;
height: number;
isAdvanced: boolean;
}): SystemChart[] => {
const selectedCharts = isAdvanced ? ALL_SYSTEM_CHARTS : configuredCharts;
const availableCharts = selectedCharts.filter(
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
);
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height));
};
export default function SystemResources({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const t = useScopedI18n("widget.systemResources");
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(healthQuery) ?? [];
const data = results.filter(
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const { dataUpdatedAt, isPending } = healthQuery;
const isAdvanced = displayMode === "advanced";
const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>({});
const previousIntegrationKey = useRef(integrationKey);
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
useEffect(() => {
if (previousIntegrationKey.current === integrationKey) return;
@@ -80,7 +128,7 @@ export default function SystemResources({
const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1;
const panelWidth = width / panelColumns;
const renderCharts = (entry: (typeof data)[number], availableWidth: number) => {
const renderCharts = (entry: (typeof data)[number], availableWidth: number, availableHeight: number) => {
const currentItem = toChartItem(entry.healthInfo);
const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem];
const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE);
@@ -90,38 +138,71 @@ export default function SystemResources({
key={entry.integrationId}
integrationName={entry.integrationName}
items={history}
hasGpu={entry.healthInfo.gpu.length > 0}
memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes}
options={options}
width={availableWidth}
height={availableHeight}
isAdvanced={isAdvanced}
showTitle={isAdvanced || data.length > 1}
/>
);
};
if (!isAdvanced && data.length === 1 && data[0]) {
if (isPending) {
return (
<Box h="100%" p="xs">
{renderCharts(data[0], width)}
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (data.length === 0) {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
}
if (!isAdvanced && data.length === 1 && data[0]) {
return (
<Box h="100%" p="xs" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
{renderCharts(data[0], width, Math.max(0, height - 20))}
</Box>
);
}
const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2)));
return (
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth))}
</SimpleGrid>
</ScrollArea>
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
</SimpleGrid>
</ScrollArea>
</Box>
);
}
interface SystemChartsProps {
integrationName: string;
items: ChartItem[];
hasGpu: boolean;
memoryCapacityInBytes: number;
options: WidgetComponentProps<"systemResources">["options"];
width: number;
height: number;
isAdvanced: boolean;
showTitle: boolean;
}
@@ -129,53 +210,70 @@ interface SystemChartsProps {
const SystemCharts = ({
integrationName,
items,
hasGpu,
memoryCapacityInBytes,
options,
width,
height,
isAdvanced,
showTitle,
}: SystemChartsProps) => {
const showNetwork =
items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network");
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts,
hasGpu,
hasNetwork: networkItems.length > 0,
height,
isAdvanced,
});
const showNetwork = visibleCharts.includes("network");
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length;
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const chartHeight = isAdvanced
? 180
: `calc((100% - ${Math.max(0, chartCount - 1) * 8}px) / ${Math.max(1, chartCount)})`;
: `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`;
return (
<Stack gap="xs" h={isAdvanced ? "auto" : "100%"} miw={0}>
<Stack gap="xs" h={isAdvanced ? "auto" : height} miw={0}>
{showTitle && (
<Text size="sm" fw={600} truncate="end">
{integrationName}
</Text>
)}
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1 }}>
{options.visibleCharts.includes("cpu") && (
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1, minHeight: 0 }}>
{chartCount === 0 && (
<Center h="100%">
<Text size="sm" c="dimmed">
—
</Text>
</Center>
)}
{visibleCharts.includes("cpu") && (
<Box h={chartHeight}>
<SystemResourceCPUChart
cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
)}
{options.visibleCharts.includes("memory") && (
{visibleCharts.includes("memory") && (
<Box h={chartHeight}>
<SystemResourceMemoryChart
memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
)}
{options.visibleCharts.includes("gpu") && (
{visibleCharts.includes("gpu") && (
<Box h={chartHeight}>
<SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
)}
@@ -183,24 +281,24 @@ const SystemCharts = ({
(width >= 300 ? (
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
<NetworkTrafficChart
usageOverTime={items.map((item) => item.network?.down ?? 0)}
usageOverTime={networkItems.map((network) => network.down)}
isUp={false}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
<NetworkTrafficChart
usageOverTime={items.map((item) => item.network?.up ?? 0)}
usageOverTime={networkItems.map((network) => network.up)}
isUp
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Group>
) : (
<Box h={chartHeight}>
<CombinedNetworkTrafficChart
usageOverTime={items.map((item) => item.network ?? { up: 0, down: 0 })}
usageOverTime={networkItems}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
))}
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
import { appendBoundedHistory, toChartItem } from "./component";
import {
appendBoundedHistory,
getCompactChartBudget,
getNetworkHistory,
getVisibleSystemCharts,
toChartItem,
} from "./component";
describe("system resource history", () => {
test("averages all GPU values", () => {
@@ -18,4 +24,46 @@ describe("system resource history", () => {
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
});
test("omits unavailable network samples instead of inventing zero traffic", () => {
const missing = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
const available = toChartItem({
cpuUtilization: 1,
memUsedInBytes: 2,
gpu: [],
network: { up: 12, down: 34 },
});
expect(getNetworkHistory([missing, available, missing])).toEqual([{ up: 12, down: 34 }]);
});
test("limits compact charts to the available height", () => {
expect(getCompactChartBudget(100)).toBe(1);
expect(getCompactChartBudget(200)).toBe(2);
expect(getCompactChartBudget(300)).toBe(4);
});
test("advanced mode exposes every available chart regardless of compact configuration", () => {
expect(
getVisibleSystemCharts({
configuredCharts: ["cpu"],
hasGpu: true,
hasNetwork: true,
height: 100,
isAdvanced: true,
}),
).toEqual(["cpu", "memory", "gpu", "network"]);
});
test("advanced mode omits charts whose data is unavailable", () => {
expect(
getVisibleSystemCharts({
configuredCharts: ["gpu", "network"],
hasGpu: false,
hasNetwork: false,
height: 300,
isAdvanced: true,
}),
).toEqual(["cpu", "memory"]);
});
});
+62 -15
View File
@@ -12,6 +12,7 @@ export default function TimetableWidget({
options,
displayMode = "compact",
width,
height,
itemId,
}: WidgetComponentProps<"timetable">) {
const t = useScopedI18n("widget.timetable");
@@ -27,6 +28,7 @@ export default function TimetableWidget({
itemId={itemId}
displayMode={displayMode}
width={width}
height={height}
/>
);
}
@@ -37,16 +39,22 @@ interface TimetableWidgetInnerProps {
itemId?: string;
displayMode: "compact" | "advanced";
width: number;
height: number;
}
const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }: TimetableWidgetInnerProps) => {
const { data: timetable, error } = clientApi.widget.timetable.getTimetable.useQuery({
const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, height }: TimetableWidgetInnerProps) => {
const {
data: timetable,
error,
isPending,
} = clientApi.widget.timetable.getTimetable.useQuery({
baseUrl,
itemId,
stationId: station.value,
limit: displayMode === "advanced" ? 50 : 10,
});
const t = useScopedI18n("widget.timetable");
const tCommon = useScopedI18n("common");
if (error && timetable === undefined) throw error;
@@ -54,10 +62,19 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }:
const staleTime = timetable ? dayjs(timetable.timestamp).format("HH:mm:ss") : undefined;
const staleWarning = error && staleTime ? t("warning.stale", { time: staleTime }) : undefined;
const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && (width < 300 || height < 160);
const showTitle = isAdvanced || height >= 112;
const showLine = isAdvanced || width >= 200;
const showPlatform = isAdvanced || width >= 320;
return (
<Stack w="100%" h="100%" gap="xs" p="sm">
<Text fw="bold">{t("title", { station: station.label })}</Text>
<Stack w="100%" h="100%" gap={isDense ? 4 : "xs"} p={isDense ? "xs" : "sm"}>
{showTitle && (
<Text fw={600} size={isDense ? "sm" : undefined} truncate="end">
{t("title", { station: station.label })}
</Text>
)}
{compactStaleWarning && displayMode === "compact" && (
<Group gap={2} wrap="nowrap">
<IconAlertTriangle aria-hidden size={12} color="var(--mantine-color-orange-light-color)" />
@@ -73,9 +90,29 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }:
)}
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<SimpleGrid cols={displayMode === "advanced" && width >= 760 ? 2 : 1} spacing="xs">
{entries.map((entry) => (
<DepartureRow key={`${entry.timestamp.toISOString()}-${entry.location}`} entry={entry} />
))}
{isPending ? (
<Center mih={64} style={{ gridColumn: "1 / -1" }}>
<Text size="sm" c="dimmed">
{tCommon("action.loading")}
</Text>
</Center>
) : entries.length > 0 ? (
entries.map((entry) => (
<DepartureRow
key={`${entry.timestamp.toISOString()}-${entry.location}`}
entry={entry}
dense={isDense}
showLine={showLine}
showPlatform={showPlatform}
/>
))
) : (
<Center mih={64} style={{ gridColumn: "1 / -1" }}>
<Text size="sm" c="dimmed">
—
</Text>
</Center>
)}
</SimpleGrid>
</ScrollArea>
{displayMode === "advanced" && timetable && (
@@ -95,11 +132,21 @@ interface TimetableEntryView {
platform: { name: string; hasChanged: boolean } | null;
}
function DepartureRow({ entry }: { entry: TimetableEntryView }) {
function DepartureRow({
entry,
dense,
showLine,
showPlatform,
}: {
entry: TimetableEntryView;
dense: boolean;
showLine: boolean;
showPlatform: boolean;
}) {
return (
<Group justify="space-between" w="100%" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
{entry.line && (
<Group gap={dense ? 6 : "sm"} wrap="nowrap" style={{ minWidth: 0 }}>
{showLine && entry.line && (
<Badge
size="sm"
color={entry.line.color ?? undefined}
@@ -111,21 +158,21 @@ function DepartureRow({ entry }: { entry: TimetableEntryView }) {
{entry.line.name}
</Badge>
)}
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size={dense ? "xs" : "sm"} fw={dense ? 600 : undefined} style={{ whiteSpace: "nowrap" }}>
{dayjs(entry.timestamp).format("HH:mm")}{" "}
{entry.delay >= 1 && (
<Text size="sm" span c="red">
<Text size={dense ? "xs" : "sm"} span c="red">
+{entry.delay}&apos;
</Text>
)}
</Text>
<Text size="sm" truncate>
<Text size={dense ? "xs" : "sm"} truncate>
{entry.location}
</Text>
</Group>
{entry.platform && (
<Text size="sm" c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
{showPlatform && entry.platform && (
<Text size={dense ? "xs" : "sm"} c={entry.platform.hasChanged ? "red" : undefined} style={{ flexShrink: 0 }}>
{entry.platform.name}
</Text>
)}
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getAdvancedColumnWidth } from "./component";
import { getAdvancedColumnWidth, getCompactSectionVisibility } from "./component";
describe("Tracearr advanced layout", () => {
it("subtracts grid padding and the inter-column gap", () => {
@@ -12,3 +12,29 @@ describe("Tracearr advanced layout", () => {
expect(getAdvancedColumnWidth(799, false)).toBe(767);
});
});
describe("Tracearr compact disclosure", () => {
it("keeps urgent violations visible in a short widget", () => {
expect(
getCompactSectionVisibility({
height: 160,
showStreams: true,
showViolations: true,
hasViolations: true,
showRecentActivity: true,
}),
).toEqual({ violations: true, recentActivity: false });
});
it("reveals secondary history as height becomes available", () => {
expect(
getCompactSectionVisibility({
height: 400,
showStreams: true,
showViolations: true,
hasViolations: false,
showRecentActivity: true,
}),
).toEqual({ violations: true, recentActivity: true });
});
});
+26 -2
View File
@@ -18,6 +18,23 @@ const ADVANCED_GRID_BREAKPOINT = 800;
const ADVANCED_GRID_GAP_PX = 16;
const ADVANCED_GRID_PADDING_PX = 16;
export const getCompactSectionVisibility = ({
height,
showStreams,
showViolations,
hasViolations,
showRecentActivity,
}: {
height: number;
showStreams: boolean;
showViolations: boolean;
hasViolations: boolean;
showRecentActivity: boolean;
}) => ({
violations: showViolations && (hasViolations || height >= 240 || !showStreams),
recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))),
});
export default function TracearrWidget({
options,
integrationIds,
@@ -124,6 +141,13 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit);
const violations = combined.violations?.data ?? [];
const recentActivity = combined.recentActivity?.data ?? [];
const compactSections = getCompactSectionVisibility({
height,
showStreams: options.showStreams,
showViolations: options.showViolations,
hasViolations: violations.length > 0,
showRecentActivity: options.showRecentActivity,
});
if (displayMode === "advanced") {
const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT;
@@ -154,9 +178,9 @@ function TracearrContent({ integrationIds, options, width, height, displayMode }
<ScrollArea h="100%">
<Stack gap="xs" p="xs">
{options.showStats && <StatsBar stats={combined.stats} summary={combined.streams.summary} width={width} />}
{compactSections.violations && <ViolationsList violations={violations.slice(0, compactLimit)} />}
{options.showStreams && <StreamsList streams={streams} width={width} />}
{options.showViolations && <ViolationsList violations={violations.slice(0, compactLimit)} />}
{options.showRecentActivity && <RecentActivityList sessions={recentActivity.slice(0, compactLimit)} />}
{compactSections.recentActivity && <RecentActivityList sessions={recentActivity.slice(0, compactLimit)} />}
{noSectionsEnabled && (
<Text c="dimmed" ta="center">
{t("noSectionsEnabled")}
@@ -8,7 +8,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
<Text size="xs" fw={600} c="dimmed">
{t("recentActivity.title")}
</Text>
{sessions.length === 0 ? (
@@ -9,7 +9,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[]
return (
<Stack gap={4}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
<Text size="xs" fw={600} c="dimmed">
{t("violations.title")}
</Text>
{violations.length === 0 ? (
+1 -1
View File
@@ -126,7 +126,7 @@ function TraefikWidgetContent({
))}
</div>
{options.showEntryPoints && combined.entryPoints.length > 0 && (
{options.showEntryPoints && combined.entryPoints.length > 0 && (displayMode === "advanced" || height >= 340) && (
<div className={classes.entryPoints}>
{dedupe(combined.entryPoints)
.slice(0, getEntryPointLimit(width))
+43 -25
View File
@@ -43,10 +43,11 @@ export function UmamiContent({
displayMode,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
const tCommon = useScopedI18n("common");
const { colorScheme } = useMantineColorScheme();
const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057";
const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery(
const { data: results = [], isPending: isStatsPending } = clientApi.widget.umami.getVisitorStats.useQuery(
{ integrationIds, websiteId, timeFrame, eventName },
umamiQueryOptions,
);
@@ -67,6 +68,15 @@ export function UmamiContent({
: undefined;
const firstResult = results[0];
if (isStatsPending) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (!firstResult) {
return (
<Stack align="center" justify="center" h="100%">
@@ -93,6 +103,11 @@ export function UmamiContent({
const isOverlay = chartStyle === "overlay" && hasEventSeries;
const overlayBarSize = 16;
const isAdvanced = displayMode === "advanced";
const isDense = !isAdvanced && height < 120;
const showXAxis = isAdvanced || height >= 140;
const showSecondaryStats = isAdvanced || height >= 96;
const showDetailedStats = isAdvanced || (width >= 260 && height >= 150);
const selectedView =
viewMode === "events" ? (
@@ -102,6 +117,7 @@ export function UmamiContent({
timeFrame={timeFrame}
eventNames={eventNames}
chartType={chartType}
showXAxis={showXAxis}
/>
) : viewMode === "topPages" ? (
<UmamiTopPagesContent
@@ -129,7 +145,7 @@ export function UmamiContent({
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withXAxis={showXAxis}
withYAxis={false}
xAxisProps={{ tick: { fontSize: 9, fill: tickColor }, interval: "preserveStartEnd" }}
/>
@@ -143,7 +159,7 @@ export function UmamiContent({
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withXAxis={showXAxis}
withYAxis={false}
barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }}
barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined}
@@ -190,7 +206,7 @@ export function UmamiContent({
);
return (
<Stack gap={4} p="xs" h="100%">
<Stack gap={isDense ? 2 : 4} p={isDense ? 4 : "xs"} h="100%">
<Group justify="space-between" align="baseline" wrap="nowrap">
<Text size="xs" c="dimmed" truncate="end" style={{ maxWidth: "55%" }}>
{visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)})
@@ -231,27 +247,29 @@ export function UmamiContent({
</Group>
)}
</Group>
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
<Text size="xs" c="green">
● {activeVisitors.toLocaleString()} {t("active")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
</Text>
{(displayMode === "advanced" || width >= 260) && (
<>
<Text size="xs" c="dimmed">
{visitorStats.bounceRate}% {t("bounceRate")}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
</Text>
</>
)}
</Group>
{showSecondaryStats && (
<Group wrap="wrap" style={{ columnGap: 12, rowGap: 4 }}>
<Text size="xs" c="green">
● {activeVisitors.toLocaleString()} {t("active")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalPageviews.toLocaleString()} {t("pageviews")}
</Text>
<Text size="xs" c="dimmed">
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
</Text>
{showDetailedStats && (
<>
<Text size="xs" c="dimmed">
{visitorStats.bounceRate}% {t("bounceRate")}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
</Text>
</>
)}
</Group>
)}
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
@@ -15,6 +15,7 @@ interface UmamiEventsContentProps {
timeFrame: string;
eventNames: string[];
chartType: string;
showXAxis: boolean;
}
export function UmamiEventsContent({
@@ -23,6 +24,7 @@ export function UmamiEventsContent({
timeFrame,
eventNames,
chartType,
showXAxis,
}: UmamiEventsContentProps) {
const t = useScopedI18n("widget.umami");
const { colorScheme } = useMantineColorScheme();
@@ -104,7 +106,7 @@ export function UmamiEventsContent({
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withXAxis={showXAxis}
withYAxis={false}
xAxisProps={{
tick: { fontSize: 9, fill: tickColor },
@@ -121,7 +123,7 @@ export function UmamiEventsContent({
gridAxis="none"
withLegend={false}
withTooltip
withXAxis
withXAxis={showXAxis}
withYAxis={false}
barProps={{ radius: 2 }}
xAxisProps={{
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { aggregateUptimeKumaDashboards } from "./aggregate";
import { getCompactStatLimit, getCompactStatPriority } from "./component";
describe("aggregateUptimeKumaDashboards", () => {
it("weights uptime by monitors instead of integrations", () => {
@@ -29,3 +30,26 @@ describe("aggregateUptimeKumaDashboards", () => {
expect(combined.averageUptimePercent).toBe(25);
});
});
describe("getCompactStatLimit", () => {
it("reserves compact height for the uptime summary", () => {
expect(getCompactStatLimit(150, true, 4)).toBe(0);
expect(getCompactStatLimit(220, true, 4)).toBe(2);
});
it("uses the space for metrics when the summary is disabled", () => {
expect(getCompactStatLimit(100, false, 4)).toBe(2);
expect(getCompactStatLimit(240, false, 4)).toBe(4);
});
});
describe("getCompactStatPriority", () => {
it("puts active failures before healthy totals", () => {
expect(getCompactStatPriority("downCount", 2)).toBeLessThan(getCompactStatPriority("totalMonitors", 10));
expect(getCompactStatPriority("pausedCount", 1)).toBeLessThan(getCompactStatPriority("upCount", 8));
});
it("moves zero-value problem states behind healthy totals", () => {
expect(getCompactStatPriority("downCount", 0)).toBeGreaterThan(getCompactStatPriority("upCount", 8));
});
});
+37 -4
View File
@@ -76,6 +76,25 @@ const heroVariantByRing = {
false: classes.heroTextOnly,
} as const;
export function getCompactStatLimit(height: number, showHero: boolean, statCount: number): number {
if (showHero) {
if (height < 180) return 0;
if (height < 260) return Math.min(2, statCount);
if (height < 340) return Math.min(4, statCount);
} else {
if (height < 130) return Math.min(2, statCount);
if (height < 220) return Math.min(4, statCount);
}
return statCount;
}
export function getCompactStatPriority(statKey: keyof typeof statIcons, value: number): number {
if (statKey === "downCount") return value > 0 ? 0 : 4;
if (statKey === "pausedCount") return value > 0 ? 1 : 5;
if (statKey === "totalMonitors") return 2;
return 3;
}
export default function UptimeKumaWidget({
integrationIds,
options,
@@ -126,16 +145,30 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
const iconSize = getIconSize(width);
const gridCols = getGridCols(width);
const visibleStatKeys = Object.entries(statVisibilityByOption)
const enabledStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const showHero = options.showAverageUptime;
const showRing = options.showUptimeRing && (displayMode === "advanced" || (width >= 170 && height >= 110));
const prioritizedStatKeys =
displayMode === "advanced"
? enabledStatKeys
: enabledStatKeys.toSorted(
(left, right) =>
getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]),
);
const visibleStatKeys = prioritizedStatKeys.slice(
0,
displayMode === "advanced"
? prioritizedStatKeys.length
: getCompactStatLimit(height, showHero, prioritizedStatKeys.length),
);
const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass =
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing];
const heroRingClass = heroVariantByRing[String(showRing) as keyof typeof heroVariantByRing];
const summaryContent = (
<div className={classes.root}>
@@ -145,7 +178,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<span className={classes.heroLabel}>{t("averageUptime")}</span>
<span className={classes.heroValue}>{formatNumber(uptimeValue, 1)}%</span>
</div>
{options.showUptimeRing && (
{showRing && (
<RingProgress
className={classes.ring}
size={ringSize}
@@ -190,7 +223,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
})}
</div>
)}
{displayMode === "compact" && height >= 240 && combined.downCount > 0 && (
{displayMode === "compact" && height >= 300 && combined.downCount > 0 && (
<Stack gap={2} px="sm">
{monitors
.filter((monitor) => monitor.status === "down")
+19 -2
View File
@@ -1,8 +1,9 @@
"use client";
import { Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { VpnIntegrationCard } from "./vpn-card";
@@ -11,12 +12,26 @@ export default function VpnWidget({
options,
integrationIds,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"vpn">) {
const { data: integrations = [] } = clientApi.widget.vpn.getSummaries.useQuery({
const { data: integrations = [], isPending } = clientApi.widget.vpn.getSummaries.useQuery({
...options,
integrationIds,
});
const t = useScopedI18n("widget.vpn");
const tCommon = useScopedI18n("common");
const dense = displayMode === "compact" && (width < 240 || height < 120);
if (isPending || integrations.length === 0) {
return (
<Center h="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{isPending ? tCommon("action.loading") : t("serviceUnavailable")}
</Text>
</Center>
);
}
const [vpn] = integrations;
if (displayMode === "compact" && integrations.length === 1 && vpn) {
@@ -26,6 +41,7 @@ export default function VpnWidget({
vpn={vpn.summary}
integrationName={vpn.integration.name}
variant={width < 240 ? "list" : "single"}
dense={dense}
/>
</Flex>
);
@@ -59,6 +75,7 @@ export default function VpnWidget({
vpn={result.summary}
integrationName={result.integration.name}
variant="list"
dense={dense}
/>
))}
</Stack>
+57 -24
View File
@@ -1,8 +1,15 @@
"use client";
import React from "react";
import { Badge, Box, Card, Flex, Group, Text } from "@mantine/core";
import { IconArrowsExchange, IconMapPin, IconShieldCheck, IconShieldX } from "@tabler/icons-react";
import { Badge, Box, Card, Flex, Group, Text, VisuallyHidden } from "@mantine/core";
import {
IconArrowsExchange,
IconCircleCheckFilled,
IconCircleXFilled,
IconMapPin,
IconShieldCheck,
IconShieldX,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -16,17 +23,19 @@ export function VpnIntegrationCard({
vpn,
integrationName,
variant = "single",
dense = false,
}: {
vpn: VpnInfo;
integrationName?: string;
variant?: "single" | "list";
dense?: boolean;
}) {
const board = useRequiredBoard();
const compact = variant === "list";
const content = vpn ? (
<Flex direction="row" w="100%" align="center" gap="xs">
<VpnStatusColumn vpnStatus={vpn.vpnStatus} dnsStatus={vpn.dnsStatus} compact={compact} />
<VpnStatusColumn vpnStatus={vpn.vpnStatus} dnsStatus={vpn.dnsStatus} compact={compact} dense={dense} />
<VpnInfoColumn
integrationName={compact ? integrationName : undefined}
publicIp={vpn.publicIp}
@@ -35,15 +44,16 @@ export function VpnIntegrationCard({
provider={vpn.vpnProvider.provider}
protocol={vpn.vpnProvider.protocol}
compact={compact}
dense={dense}
/>
</Flex>
) : (
<VpnUnavailableContent compact={compact} integrationName={compact ? integrationName : undefined} />
<VpnUnavailableContent compact={compact} dense={dense} integrationName={compact ? integrationName : undefined} />
);
if (compact) {
return (
<Card withBorder radius={board.itemRadius} p="xs" w="100%">
<Card withBorder radius={board.itemRadius} p={dense ? 6 : "xs"} w="100%">
{content}
</Card>
);
@@ -52,20 +62,28 @@ export function VpnIntegrationCard({
return <Box w="100%">{content}</Box>;
}
function VpnUnavailableContent({ compact, integrationName }: { compact: boolean; integrationName?: string }) {
function VpnUnavailableContent({
compact,
dense,
integrationName,
}: {
compact: boolean;
dense: boolean;
integrationName?: string;
}) {
const t = useScopedI18n("widget.vpn");
return (
<Flex direction="row" w="100%" align="center" gap="xs">
{/* Empty statuses render the shield and DNS badge in red (see getStatusColor). */}
<VpnStatusColumn vpnStatus="" dnsStatus="" compact={compact} />
<VpnStatusColumn vpnStatus="" dnsStatus="" compact={compact} dense={dense} />
<Flex gap={2} direction="column" w="100%" align="flex-start" style={{ minWidth: 0 }}>
{integrationName ? (
<Text fw={600} size="xs" lh={1.2} c="dimmed" lineClamp={1}>
{integrationName}
</Text>
) : null}
<Text fw={700} size={compact ? "md" : "lg"} lh={1.2} c="red" lineClamp={2}>
<Text fw={700} size={dense ? "sm" : compact ? "md" : "lg"} lh={1.2} c="red" lineClamp={2}>
{t("serviceUnavailable")}
</Text>
</Flex>
@@ -77,17 +95,30 @@ function VpnStatusColumn({
vpnStatus,
dnsStatus,
compact,
dense,
}: {
vpnStatus: string;
dnsStatus: string;
compact: boolean;
dense: boolean;
}) {
// Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross.
const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX;
return (
<Flex gap={4} direction="column" w={compact ? "28%" : "30%"} miw={compact ? 56 : undefined} align="center">
<ShieldIcon stroke={2} color={getStatusColor(vpnStatus)} size={compact ? 44 : 52} />
<Flex
gap={4}
direction="column"
w={compact ? "28%" : "30%"}
miw={dense ? 44 : compact ? 56 : undefined}
align="center"
>
<ShieldIcon
aria-label={vpnStatus || "unavailable"}
stroke={2}
color={getStatusColor(vpnStatus)}
size={dense ? 32 : compact ? 44 : 52}
/>
<DnsStatusBadge status={dnsStatus} />
</Flex>
);
@@ -101,6 +132,7 @@ function VpnInfoColumn({
provider,
protocol,
compact,
dense,
}: {
integrationName?: string;
publicIp: string;
@@ -109,6 +141,7 @@ function VpnInfoColumn({
provider: string;
protocol: string;
compact: boolean;
dense: boolean;
}) {
return (
<Flex gap={2} direction="column" w="100%" align="flex-start" style={{ minWidth: 0 }}>
@@ -117,16 +150,18 @@ function VpnInfoColumn({
{integrationName}
</Text>
) : null}
<Text fw={700} size={compact ? "xl" : "2xl"} lh={1.1} lineClamp={1}>
{publicIp}
<Text fw={700} size={dense ? "md" : compact ? "xl" : "2xl"} lh={1.1} lineClamp={1}>
{publicIp || "—"}
</Text>
<Group gap={4} justify="flex-start" wrap="nowrap">
<IconMapPin size={10} />
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
{city}, {country}
</Text>
</Group>
<VpnProviderDetails provider={provider} protocol={protocol} />
{!dense && (city || country) && (
<Group gap={4} justify="flex-start" wrap="nowrap">
<IconMapPin aria-hidden size={10} />
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
{[city, country].filter(Boolean).join(", ")}
</Text>
</Group>
)}
{!dense && (provider || protocol) && <VpnProviderDetails provider={provider} protocol={protocol} />}
</Flex>
);
}
@@ -145,20 +180,18 @@ function VpnProviderDetails({ provider, protocol }: { provider: string; protocol
);
}
function StatusDot({ status, size = 10 }: { status: string; size?: number }) {
return <Box bg={getStatusColor(status)} h={size} w={size} style={{ borderRadius: 999 }} />;
}
function DnsStatusBadge({ status }: { status: string }) {
const StatusIcon = status === RUNNING_STATUS ? IconCircleCheckFilled : IconCircleXFilled;
return (
<Badge
variant="outline"
color={getStatusColor(status)}
size="xs"
radius="xl"
leftSection={<StatusDot status={status} size={5} />}
leftSection={<StatusIcon aria-hidden size={9} />}
>
DNS
<VisuallyHidden> {status || "unavailable"}</VisuallyHidden>
</Badge>
);
}
+10 -4
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { getPreferredUnit, getVisibleForecastDays } from "./component";
import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component";
describe("weather formatting", () => {
it("preserves valid zero temperatures", () => {
@@ -8,9 +8,15 @@ describe("weather formatting", () => {
expect(getPreferredUnit(0, true, true)).toBe("32°F");
});
it("keeps zero wind speeds and missing forecast values truthful", () => {
expect(getPreferredWindSpeed(0)).toBe("0.0");
expect(getPreferredWindSpeed(undefined)).toBe("?");
expect(getPreferredTime(undefined)).toBe("?");
expect(getPreferredTime("invalid")).toBe("?");
});
it("limits compact forecasts by both axes", () => {
expect(getVisibleForecastDays(7, 120, 100, false)).toBe(2);
expect(getVisibleForecastDays(7, 600, 100, false)).toBe(3);
expect(getVisibleForecastDays(7, 120, 100, true)).toBe(7);
expect(getVisibleForecastDays(7, 120, 100)).toBe(2);
expect(getVisibleForecastDays(7, 600, 100)).toBe(3);
});
});
+196 -54
View File
@@ -1,7 +1,16 @@
"use client";
import { Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react";
import { useId } from "react";
import { Box, Group, HoverCard, Stack, Table, Text } from "@mantine/core";
import {
IconArrowDownRight,
IconArrowUpRight,
IconDroplets,
IconMapPin,
IconSunrise,
IconSunset,
IconWind,
} from "@tabler/icons-react";
import combineClasses from "clsx";
import dayjs from "dayjs";
@@ -30,6 +39,10 @@ export default function WeatherWidget({
if (!weather) return <WidgetEmptyState />;
if (displayMode === "advanced") {
return <AdvancedWeather weather={weather} options={options} />;
}
return (
<Stack
align="center"
@@ -43,11 +56,10 @@ export default function WeatherWidget({
<WeeklyForecast
weather={weather}
options={options}
maxDays={getVisibleForecastDays(options.forecastDayCount, width, height, displayMode === "advanced")}
showDetails={displayMode === "advanced"}
maxDays={getVisibleForecastDays(options.forecastDayCount, width, height)}
/>
) : (
<DailyWeather weather={weather} options={options} showDetails={displayMode === "advanced"} />
<DailyWeather weather={weather} options={options} />
)}
</Stack>
);
@@ -57,7 +69,172 @@ interface WeatherProps extends Pick<WidgetComponentProps<"weather">, "options">
weather: NonNullable<RouterOutputs["widget"]["weather"]["atLocation"]>;
}
const DailyWeather = ({ options, weather, showDetails = false }: WeatherProps & { showDetails?: boolean }) => {
const AdvancedWeather = ({ options, weather }: WeatherProps) => {
const forecastHeadingId = useId();
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
return (
<Stack h="100%" w="100%" gap="sm" p="md" style={{ overflow: "hidden" }}>
<Group justify="space-between" align="center" gap="sm" wrap="wrap">
<Group gap="sm" wrap="nowrap">
<AnimatedWeatherIcon size={42} code={weather.current.weathercode} />
<Stack gap={0}>
<Text fz="xl" fw={700} lh={1.1}>
{getPreferredUnit(
weather.current.temperature,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</Stack>
</Group>
<Group gap="lg" wrap="wrap">
<Group gap={5} wrap="nowrap">
<IconMapPin size={16} aria-hidden />
<Text size="sm">{options.location.name}</Text>
</Group>
<Group gap={5} wrap="nowrap">
<IconWind size={16} aria-hidden />
<Text size="sm">
{t("currentWindSpeed", {
currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed),
unit: options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour"),
})}
</Text>
</Group>
</Group>
</Group>
<Text id={forecastHeadingId} component="h3" size="sm" fw={600}>
{t("advanced.forecast")}
</Text>
<Table.ScrollContainer
minWidth={680}
maxHeight="100%"
style={{ flex: 1, minHeight: 0 }}
scrollAreaProps={{ type: "auto", offsetScrollbars: "present" }}
>
<Table
aria-labelledby={forecastHeadingId}
striped
highlightOnHover
verticalSpacing="xs"
horizontalSpacing="sm"
stickyHeader
>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("advanced.day")}</Table.Th>
<Table.Th>{t("advanced.conditions")}</Table.Th>
<Table.Th>{t("advanced.temperature")}</Table.Th>
<Table.Th>{t("advanced.humidity")}</Table.Th>
<Table.Th>
{t("advanced.wind", {
unit: options.useImperialSpeed
? tCommon("unit.speed.milesPerHour")
: tCommon("unit.speed.kilometersPerHour"),
})}
</Table.Th>
<Table.Th>{t("advanced.sun")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{weather.daily.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text size="sm" c="dimmed" ta="center">
{t("advanced.noForecast")}
</Text>
</Table.Td>
</Table.Tr>
) : (
weather.daily.map((dayWeather) => (
<Table.Tr key={dayWeather.time}>
<Table.Td>
<Stack gap={0}>
<Text size="sm" fw={600} tt="capitalize">
{dayjs(dayWeather.time).format("ddd")}
</Text>
<Text size="xs" c="dimmed">
{dayjs(dayWeather.time).format("MMM D")}
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<AnimatedWeatherIcon size={20} code={dayWeather.weatherCode} />
<WeatherDescription weatherOnly weatherCode={dayWeather.weatherCode} />
</Group>
</Table.Td>
<Table.Td>
<Group gap={4} wrap="nowrap">
<IconArrowUpRight size={15} aria-hidden />
<Text size="sm">
{getPreferredUnit(
dayWeather.maxTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
<Text size="sm" c="dimmed">
/
</Text>
<IconArrowDownRight size={15} aria-hidden />
<Text size="sm">
{getPreferredUnit(
dayWeather.minTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={5} wrap="nowrap">
<IconDroplets size={15} aria-hidden />
<Text size="sm">{dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`}</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap={5} wrap="nowrap">
<IconWind size={15} aria-hidden />
<Text size="sm">
{getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "}
{getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Group gap={3} wrap="nowrap">
<IconSunrise size={15} aria-hidden />
<Text size="sm">{getPreferredTime(dayWeather.sunrise)}</Text>
</Group>
<Text size="sm" c="dimmed">
/
</Text>
<Group gap={3} wrap="nowrap">
<IconSunset size={15} aria-hidden />
<Text size="sm">{getPreferredTime(dayWeather.sunset)}</Text>
</Group>
</Group>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
);
};
const DailyWeather = ({ options, weather }: WeatherProps) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
@@ -138,39 +315,11 @@ const DailyWeather = ({ options, weather, showDetails = false }: WeatherProps &
</Group>
</>
)}
{showDetails && weather.daily[0] && (
<WeatherDescription
useImperialSpeed={options.useImperialSpeed}
dateFormat={options.dateFormat}
time={weather.daily[0].time}
weatherCode={weather.daily[0].weatherCode}
maxTemp={getPreferredUnit(
weather.daily[0].maxTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
minTemp={getPreferredUnit(
weather.daily[0].minTemp,
options.isFormatFahrenheit,
options.disableTemperatureDecimals,
)}
sunrise={dayjs(weather.daily[0].sunrise).format("HH:mm")}
sunset={dayjs(weather.daily[0].sunset).format("HH:mm")}
maxWindSpeed={weather.daily[0].maxWindSpeed}
maxWindGusts={weather.daily[0].maxWindGusts}
humidity={weather.daily[0].humidity}
/>
)}
</>
);
};
const WeeklyForecast = ({
options,
weather,
maxDays,
showDetails,
}: WeatherProps & { maxDays: number; showDetails: boolean }) => {
const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: number }) => {
return (
<>
<Group className="weather-forecast-city-temp-group" wrap="nowrap" gap="md">
@@ -202,17 +351,12 @@ const WeeklyForecast = ({
</Text>
</Group>
</Group>
<Forecast weather={weather} options={options} maxDays={maxDays} showDetails={showDetails} />
<Forecast weather={weather} options={options} maxDays={maxDays} />
</>
);
};
function Forecast({
weather,
options,
maxDays,
showDetails,
}: WeatherProps & { maxDays: number; showDetails: boolean }) {
function Forecast({ weather, options, maxDays }: WeatherProps & { maxDays: number }) {
const dateFormat = options.dateFormat;
return (
<Group className="weather-forecast-days-group" w="100%" justify="space-evenly" wrap="nowrap" pb="sm">
@@ -233,11 +377,6 @@ function Forecast({
<Text fz={16}>
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
{showDetails && (
<Text fz="xs" c="dimmed">
{getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
</Text>
)}
</Stack>
</HoverCard.Target>
<HoverCard.Dropdown>
@@ -276,13 +415,16 @@ export const getPreferredUnit = (value?: number, isFahrenheit = false, disableTe
: `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C`
: "?";
export const getVisibleForecastDays = (
configuredDays: number,
width: number,
height: number,
isAdvanced: boolean,
): number => {
if (isAdvanced) return configuredDays;
export const getPreferredWindSpeed = (value: number | undefined, useImperialSpeed = false): string =>
value === undefined ? "?" : (useImperialSpeed ? metricToImperial(value) : value).toFixed(1);
export const getPreferredTime = (value: string | undefined): string => {
if (!value) return "?";
const time = dayjs(value);
return time.isValid() ? time.format("HH:mm") : "?";
};
export const getVisibleForecastDays = (configuredDays: number, width: number, height: number): number => {
const widthBudget = Math.max(1, Math.floor(width / 58));
const heightBudget = height < 120 ? 3 : configuredDays;
return Math.min(configuredDays, widthBudget, heightBudget);