diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
index 15acef336..6914d69f0 100644
--- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
+import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
@@ -14,7 +15,8 @@ import { ModalProvider } from "@homarr/modals";
import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
-import { BoardTourProvider } from "~/components/onboarding/board-tour";
+import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
+import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import type { Params } from "./(content)/_creator";
@@ -24,11 +26,6 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" });
-const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
- if (!hasSession) return <>{children}>;
- return {children};
-};
-
interface CreateBoardLayoutProps {
headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise;
@@ -46,8 +43,34 @@ export const createBoardLayout = ({
}: PropsWithChildren<{
params: Promise;
}>) => {
- const session = await auth();
- const initialBoard = await getInitialBoard(await params).catch((error) => {
+ const resolvedParams = await params;
+ const sessionPromise = auth();
+ const initialBoardPromise = getInitialBoard(resolvedParams).then(
+ (board) => ({ status: "fulfilled", board }) as const,
+ (error: unknown) => ({ status: "rejected", error }) as const,
+ );
+ const colorSchemePromise = getCurrentColorSchemeAsync();
+ const initialViewportWidthPromise = getInitialViewportWidthAsync();
+ const shouldRunBoardTourPromise = sessionPromise.then(async (session) => {
+ if (!withTour || !session || env.DEMO_MODE) return false;
+
+ try {
+ const user = await getRscUserSettingsAsync(session.user.id);
+ return user !== undefined && !user.completedBoardTour;
+ } catch (error) {
+ logger.error(new Error("Failed to load the board tour status", { cause: error }));
+ return false;
+ }
+ });
+ const [session, initialBoardResult, colorScheme, shouldRunBoardTour, initialViewportWidth] = await Promise.all([
+ sessionPromise,
+ initialBoardPromise,
+ colorSchemePromise,
+ shouldRunBoardTourPromise,
+ initialViewportWidthPromise,
+ ]);
+ if (initialBoardResult.status === "rejected") {
+ const { error } = initialBoardResult;
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
if (!session) {
logger.debug("No home board found for anonymous user, redirecting to login");
@@ -63,9 +86,8 @@ export const createBoardLayout = ({
}
throw error;
- });
- const colorScheme = await getCurrentColorSchemeAsync();
- const initialViewportWidth = await getInitialViewportWidthAsync();
+ }
+ const initialBoard = initialBoardResult.board;
const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth);
return (
@@ -79,16 +101,16 @@ export const createBoardLayout = ({
-
+
}
actions={headerActions}
hasNavigation={false}
/>
- {children}
+ {children}
-
+
diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
index 388212e33..1293638ba 100644
--- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
+++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
@@ -70,7 +70,7 @@
.surfaceHostAdvanced {
position: fixed;
inset: 0;
- z-index: calc(var(--mantine-z-index-modal) - 1);
+ z-index: var(--homarr-z-index-widget-focus);
width: auto;
height: auto;
pointer-events: none;
diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx
index 4f8746632..5cf3135f0 100644
--- a/apps/nextjs/src/components/board/advanced-focus/context.tsx
+++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx
@@ -229,6 +229,19 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
if (isEditMode && activeRef.current) close(false);
}, [close, isEditMode]);
+ useEffect(() => {
+ if (active?.activation !== "manual") return;
+ const background = document.querySelectorAll("[data-advanced-focus-background]");
+ background.forEach((element) => {
+ element.inert = true;
+ });
+ return () => {
+ background.forEach((element) => {
+ element.inert = false;
+ });
+ };
+ }, [active?.activation]);
+
const value = useMemo(
() => ({ active, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open],
@@ -244,7 +257,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
- zIndex="calc(var(--mantine-z-index-modal) - 2)"
+ zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx
index 7772f0d6f..9cd01848b 100644
--- a/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx
+++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx
@@ -59,7 +59,7 @@ export const AdvancedFocusManualSurface = ({
radius={radius}
padding={0}
shadow="none"
- zIndex="calc(var(--mantine-z-index-modal) - 1)"
+ zIndex="var(--homarr-z-index-widget-focus)"
closeOnEscape={false}
closeOnClickOutside
lockScroll
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx
index 8710fb631..2ad377576 100644
--- a/apps/nextjs/src/components/layout/header.tsx
+++ b/apps/nextjs/src/components/layout/header.tsx
@@ -18,7 +18,13 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
-
+
{hasNavigation && }
diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts
index 6f2731629..eb067e693 100644
--- a/e2e/board-advanced-interactions.spec.ts
+++ b/e2e/board-advanced-interactions.spec.ts
@@ -75,6 +75,8 @@ describe("Board advanced interactions", () => {
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
+ const header = page.locator("header[data-advanced-focus-background]");
+ const boardMain = page.locator("main[data-advanced-focus-background]");
await compactSurface.evaluate((element) => {
element.setAttribute("data-lifecycle-probe", "same-instance");
@@ -93,6 +95,8 @@ describe("Board advanced interactions", () => {
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
await expect(otherWidget).toBeVisible();
await expect(widget).toBeFocused();
+ await expect(header).not.toHaveAttribute("inert", "");
+ await expect(boardMain).not.toHaveAttribute("inert", "");
await previewSurface.click({ button: "right", position: { x: 80, y: 80 } });
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
@@ -136,6 +140,8 @@ describe("Board advanced interactions", () => {
await page.keyboard.press("Shift+Enter");
await expect(manualSurface).toBeVisible();
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
+ await expect(header).toHaveAttribute("inert", "");
+ await expect(boardMain).toHaveAttribute("inert", "");
expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull();
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
@@ -210,6 +216,8 @@ describe("Board advanced interactions", () => {
await page.keyboard.press("Escape");
await expect(manualSurface).toBeHidden();
await expect(widget).toBeFocused();
+ await expect(header).not.toHaveAttribute("inert", "");
+ await expect(boardMain).not.toHaveAttribute("inert", "");
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
await widget.click({ button: "right" });
diff --git a/packages/translation/src/lang/de.json b/packages/translation/src/lang/de.json
index 1429075cf..7a627d689 100644
--- a/packages/translation/src/lang/de.json
+++ b/packages/translation/src/lang/de.json
@@ -1254,6 +1254,8 @@
"success": "Erfolgreich",
"beta": "Beta",
"error": "Fehler",
+ "total": "Gesamt",
+ "filesystemUsage": "{count, plural, one {Auslastung für # Dateisystem anzeigen} other {Auslastung für # Dateisysteme anzeigen}}",
"action": {
"add": "Hinzufügen",
"apply": "Übernehmen",
@@ -1957,6 +1959,12 @@
"clickable": {
"label": "Anklickbar"
}
+ },
+ "state": {
+ "on": "Ein",
+ "off": "Aus",
+ "unavailable": "Nicht verfügbar",
+ "unknown": "Unbekannt"
}
},
"smartHome-executeAutomation": {
@@ -3422,8 +3430,13 @@
"networkControllerSummary": {
"option": {},
"card": {
+ "wan": "WAN",
+ "web": "Web",
+ "wifi": "WLAN",
+ "lan": "LAN",
"vpn": {
- "countConnected": "{count} verbunden"
+ "label": "VPN",
+ "countConnected": "{count, plural, one {# verbunden} other {# verbunden}}"
}
},
"error": {
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 1c13faeab..b207e11bd 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1853,10 +1853,6 @@
"success": "Success",
"beta": "Beta",
"error": "Error",
- "widgetQuery": {
- "loading": "Loading widget data",
- "stale": "Could not refresh {widget}; showing saved data"
- },
"action": {
"add": "Add",
"apply": "Apply",
@@ -3492,7 +3488,6 @@
"items": {
"currentlyPlaying": "Currently playing",
"user": "User",
- "unknownUser": "Unknown user",
"name": "Name",
"id": "Id",
"status": "Status",
diff --git a/packages/translation/src/lang/fr.json b/packages/translation/src/lang/fr.json
index 9c73c03c2..3549a2fd7 100644
--- a/packages/translation/src/lang/fr.json
+++ b/packages/translation/src/lang/fr.json
@@ -1405,6 +1405,8 @@
"success": "Succès",
"beta": "Bêta",
"error": "Erreur",
+ "total": "Total",
+ "filesystemUsage": "{count, plural, one {Afficher l’utilisation de # système de fichiers} other {Afficher l’utilisation de # systèmes de fichiers}}",
"action": {
"add": "Ajouter",
"apply": "Appliquer",
@@ -2108,6 +2110,12 @@
"clickable": {
"label": "Cliquable"
}
+ },
+ "state": {
+ "on": "Activé",
+ "off": "Désactivé",
+ "unavailable": "Indisponible",
+ "unknown": "Inconnu"
}
},
"smartHome-executeAutomation": {
@@ -3573,8 +3581,13 @@
"networkControllerSummary": {
"option": {},
"card": {
+ "wan": "WAN",
+ "web": "Web",
+ "wifi": "Wi-Fi",
+ "lan": "LAN",
"vpn": {
- "countConnected": "{count} connecté"
+ "label": "VPN",
+ "countConnected": "{count, plural, one {# connecté} other {# connectés}}"
}
},
"error": {
diff --git a/packages/translation/src/lang/he.json b/packages/translation/src/lang/he.json
index 089037b82..01391bed8 100644
--- a/packages/translation/src/lang/he.json
+++ b/packages/translation/src/lang/he.json
@@ -1200,6 +1200,8 @@
"success": "הצלחה",
"beta": "בטא",
"error": "שגיאה",
+ "total": "סה״כ",
+ "filesystemUsage": "{count, plural, one {הצגת השימוש במערכת קבצים אחת} other {הצגת השימוש ב-# מערכות קבצים}}",
"action": {
"add": "הוסף",
"apply": "החל",
@@ -1903,6 +1905,12 @@
"clickable": {
"label": "ניתן ללחיצה"
}
+ },
+ "state": {
+ "on": "פעיל",
+ "off": "כבוי",
+ "unavailable": "לא זמין",
+ "unknown": "לא ידוע"
}
},
"smartHome-executeAutomation": {
@@ -3368,8 +3376,13 @@
"networkControllerSummary": {
"option": {},
"card": {
+ "wan": "WAN",
+ "web": "אינטרנט",
+ "wifi": "אלחוטי",
+ "lan": "LAN",
"vpn": {
- "countConnected": "{count} מחובר"
+ "label": "VPN",
+ "countConnected": "{count, plural, one {אחד מחובר} other {# מחוברים}}"
}
},
"error": {
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index 07a86f6a3..2a3d7b0eb 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -3,3 +3,11 @@
@import "@mantine/dates/styles.css";
@import "@gfazioli/mantine-onboarding-tour/styles.css";
@import "mantine-react-table/styles.css";
+
+:root {
+ --homarr-z-index-widget-background-tooltip: calc(var(--mantine-z-index-modal) - 4);
+ --homarr-z-index-widget-preview-backdrop: calc(var(--mantine-z-index-modal) - 3);
+ --homarr-z-index-board-header: calc(var(--mantine-z-index-modal) - 2);
+ --homarr-z-index-widget-focus: calc(var(--mantine-z-index-modal) - 1);
+ --homarr-z-index-modal-tooltip: calc(var(--mantine-z-index-modal) + 2);
+}
diff --git a/packages/widgets/src/beszel/_shared/disk-usage.module.css b/packages/widgets/src/beszel/_shared/disk-usage.module.css
new file mode 100644
index 000000000..68cfb396e
--- /dev/null
+++ b/packages/widgets/src/beszel/_shared/disk-usage.module.css
@@ -0,0 +1,11 @@
+.target {
+ min-width: 24px;
+ min-height: 24px;
+}
+
+@media (pointer: coarse) {
+ .target {
+ min-width: 44px;
+ min-height: 44px;
+ }
+}
diff --git a/packages/widgets/src/beszel/_shared/disk-usage.tsx b/packages/widgets/src/beszel/_shared/disk-usage.tsx
index 934981daf..ce7ec95f8 100644
--- a/packages/widgets/src/beszel/_shared/disk-usage.tsx
+++ b/packages/widgets/src/beszel/_shared/disk-usage.tsx
@@ -1,8 +1,12 @@
-import { Box, getDefaultZIndex, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
+import { useEffect, useState } from "react";
+import { Box, Group, Popover, Progress, Stack, Text, UnstyledButton } from "@mantine/core";
+
+import { useScopedI18n } from "@homarr/translation/client";
import type { BeszelSystemRow } from "./types";
import { thresholdColor } from "./colors";
import { formatPercent, getProgressTrackSize } from "./format";
+import classes from "./disk-usage.module.css";
interface DiskUsageProps {
system: BeszelSystemRow;
@@ -17,34 +21,44 @@ const mountLabel = (path: string) =>
const severityColor = (value: number) => `var(--mantine-color-${thresholdColor(value)}-6)`;
export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = 6 }: DiskUsageProps) => {
+ const t = useScopedI18n("common");
+ const [opened, setOpened] = useState(false);
const filesystems = Object.entries(system.extraFilesystems).filter(([path]) => path !== "/");
+ const filesystemUsageLabel = t("filesystemUsage", { count: filesystems.length + 1 });
const trackSize = getProgressTrackSize(progressSize);
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
+ useEffect(() => {
+ if (filesystems.length === 0) setOpened(false);
+ }, [filesystems.length]);
+
return (
{formatPercent(system.disk)}
-
-
- {filesystems.length > 0 ? (
+ {filesystems.length > 0 ? (
+
+
setOpened(true)}
+ onMouseLeave={() => setOpened(false)}
+ onBlur={() => setOpened(false)}
+ onClick={() => setOpened(true)}
style={{
display: "flex",
alignItems: "center",
flex: 1,
- minWidth: 24,
cursor: "pointer",
}}
>
@@ -75,32 +89,32 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
- ) : (
-
-
-
-
-
- )}
-
-
-
- {[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
-
-
- {mountLabel(path)}
-
-
-
- {formatPercent(value)}
+
+
+
+ {[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
+
+
+ {mountLabel(path)}
-
-
-
- ))}
-
-
-
+
+
+ {formatPercent(value)}
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+
+
+
+
+ )}
);
};
diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx
index 548902ce9..c2042a4a9 100644
--- a/packages/widgets/src/beszel/_shared/stats-view.tsx
+++ b/packages/widgets/src/beszel/_shared/stats-view.tsx
@@ -20,16 +20,23 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
+import type { BeszelTooltipLayer } from "./tooltip";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
-const tooltipPercent = makeTooltipProps(formatPercent);
-const tooltipGB = makeTooltipProps(formatGB, true);
-const tooltipRate = makeTooltipProps(formatByteRate, true);
-const tooltipPercentTotal = makeTooltipProps(formatPercent, true);
-const tooltipBytesTotal = makeTooltipProps(formatStorageBytes, true);
+const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
+ percent: makeTooltipProps(formatPercent, false, layer),
+ gigabytes: makeTooltipProps(formatGB, true, layer),
+ rate: makeTooltipProps(formatByteRate, true, layer),
+ percentTotal: makeTooltipProps(formatPercent, true, layer),
+ bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
+});
+const tooltipSets = {
+ background: makeTooltipSet("background"),
+ modal: makeTooltipSet("modal"),
+};
const ChartSkeleton = () => (
@@ -59,6 +66,7 @@ interface BeszelStatsViewProps {
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
+ tooltipLayer?: BeszelTooltipLayer;
}
const whenVisible = (visible: boolean, data: T | undefined) => (visible ? data : undefined);
@@ -70,11 +78,13 @@ export function BeszelStatsView({
visibility,
columns,
onSwitchToHistorical,
+ tooltipLayer = "background",
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
+ const tooltips = tooltipSets[tooltipLayer];
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
@@ -256,7 +266,7 @@ export function BeszelStatsView({
series: series.cpu,
yAxisFormatter: chartAxisFormatters.percent,
yAxisDomain: CPU_Y_AXIS_DOMAIN,
- tooltipProps: tooltipPercent,
+ tooltipProps: tooltips.percent,
}}
/>
)}
@@ -270,7 +280,7 @@ export function BeszelStatsView({
type: "stacked",
series: series.memory,
yAxisFormatter: chartAxisFormatters.gb,
- tooltipProps: tooltipGB,
+ tooltipProps: tooltips.gigabytes,
}}
/>
)}
@@ -284,7 +294,7 @@ export function BeszelStatsView({
type: "stacked",
series: diskSeries,
yAxisFormatter: chartAxisFormatters.gb,
- tooltipProps: tooltipGB,
+ tooltipProps: tooltips.gigabytes,
}}
/>
)}
@@ -297,7 +307,7 @@ export function BeszelStatsView({
data: diskIOData,
series: series.diskIO,
yAxisFormatter: chartAxisFormatters.rate,
- tooltipProps: tooltipRate,
+ tooltipProps: tooltips.rate,
}}
/>
)}
@@ -310,7 +320,7 @@ export function BeszelStatsView({
data: networkData,
series: series.network,
yAxisFormatter: chartAxisFormatters.rate,
- tooltipProps: tooltipRate,
+ tooltipProps: tooltips.rate,
}}
/>
)}
@@ -326,7 +336,7 @@ export function BeszelStatsView({
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.percent,
- tooltipProps: tooltipPercentTotal,
+ tooltipProps: tooltips.percentTotal,
}}
/>
)}
@@ -340,7 +350,7 @@ export function BeszelStatsView({
type: "stacked",
series: containerSeries,
yAxisFormatter: chartAxisFormatters.bytes,
- tooltipProps: tooltipBytesTotal,
+ tooltipProps: tooltips.bytesTotal,
}}
/>
)}
@@ -353,7 +363,7 @@ export function BeszelStatsView({
data: dockerNetworkData,
series: containerSeries,
yAxisFormatter: chartAxisFormatters.rate,
- tooltipProps: tooltipRate,
+ tooltipProps: tooltips.rate,
}}
/>
)}
diff --git a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx
index fda3146d2..bc13d498b 100644
--- a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx
+++ b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx
@@ -50,6 +50,7 @@ export const BeszelSystemStatsModal = createModal((
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
+ tooltipLayer="modal"
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx
index 38c0eceea..f61991d27 100644
--- a/packages/widgets/src/dns-hole/summary/component.tsx
+++ b/packages/widgets/src/dns-hole/summary/component.tsx
@@ -1,6 +1,5 @@
"use client";
-import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import {
Avatar,
@@ -46,7 +45,7 @@ export default function DnsHoleSummaryWidget({
const t = useI18n();
- const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
+ const data = summaries.flatMap(({ summary }) => summary);
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
@@ -120,7 +119,7 @@ const stats = [
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedToday"),
- color: "rgba(240, 82, 60, 0.4)", // RED
+ color: "var(--mantine-color-red-light)",
},
{
icon: IconPercentage,
@@ -130,7 +129,7 @@ const stats = [
return `${formatNumber(totalCount === 0 ? 0 : (blocked / totalCount) * 100, size === "sm" ? 0 : 2)}%`;
},
label: (t) => t("widget.dnsHoleSummary.data.adsBlockedTodayPercentage"),
- color: "rgba(255, 165, 20, 0.4)", // YELLOW
+ color: "var(--mantine-color-yellow-light)",
},
{
icon: IconSearch,
@@ -140,7 +139,7 @@ const stats = [
size === "sm" ? 0 : 2,
),
label: (t) => t("widget.dnsHoleSummary.data.dnsQueriesToday"),
- color: "rgba(0, 175, 218, 0.4)", // BLUE
+ color: "var(--mantine-color-cyan-light)",
},
{
icon: IconWorldWww,
@@ -156,7 +155,7 @@ const stats = [
},
tooltip: (data, t) => (data.length >= 2 ? t("widget.dnsHoleSummary.domainsTooltip") : undefined),
label: (t) => t("widget.dnsHoleSummary.data.domainsBeingBlocked"),
- color: "rgba(0, 176, 96, 0.4)", // GREEN
+ color: "var(--mantine-color-green-light)",
},
] satisfies StatItem[];
diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx
index 7f1a355b1..1584e70a7 100644
--- a/packages/widgets/src/network-controller/summary/component.tsx
+++ b/packages/widgets/src/network-controller/summary/component.tsx
@@ -76,7 +76,7 @@ export default function NetworkControllerSummaryWidget({
/>
}
>
- WAN
+ {t("widget.networkControllerSummary.card.wan")}
- WWW
+ {t("widget.networkControllerSummary.card.web")}
{summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""}
@@ -103,7 +103,8 @@ export default function NetworkControllerSummaryWidget({
/>
}
>
- Wi-Fi{showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
+ {t("widget.networkControllerSummary.card.wifi")}
+ {showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
{showDetails && (
}
>
- LAN · {summary.lan.users + summary.lan.guests}
+ {t("widget.networkControllerSummary.card.lan")} · {summary.lan.users + summary.lan.guests}
)}
- VPN
+ {t("widget.networkControllerSummary.card.vpn.label")}
- {t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${summary.vpn.users}` })}
+ {t("widget.networkControllerSummary.card.vpn.countConnected", { count: summary.vpn.users })}
diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx
index 38ea95bbb..f5d6a2ed9 100644
--- a/packages/widgets/src/smart-home/entity-state/component.tsx
+++ b/packages/widgets/src/smart-home/entity-state/component.tsx
@@ -11,6 +11,8 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
+import { isSmartHomeTiny } from "../layout";
+import { getEntityStateLabel } from "./state";
export default function SmartHomeEntityStateWidget({
options,
@@ -95,7 +97,7 @@ export default function SmartHomeEntityStateWidget({
if (entityError && entity === undefined) throw entityError;
- const isTiny = width < 128 || height < 96;
+ const isTiny = isSmartHomeTiny(width, height);
const advancedAttributes = [
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement },
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
@@ -105,7 +107,13 @@ export default function SmartHomeEntityStateWidget({
displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
? entity.attributes.friendly_name
: options.displayName;
- const state = entity?.state ?? "—";
+ const knownStates = {
+ on: t("state.on"),
+ off: t("state.off"),
+ unavailable: t("state.unavailable"),
+ unknown: t("state.unknown"),
+ };
+ const state = getEntityStateLabel(entity?.state, knownStates);
return (
{
+ test("localizes known states and preserves custom states", () => {
+ expect(getEntityStateLabel("on", labels)).toBe("On");
+ expect(getEntityStateLabel("heating", labels)).toBe("heating");
+ });
+
+ test.each(["__proto__", "constructor", "toString"])("preserves prototype-like state %s", (state) => {
+ expect(getEntityStateLabel(state, labels)).toBe(state);
+ });
+});
diff --git a/packages/widgets/src/smart-home/entity-state/state.ts b/packages/widgets/src/smart-home/entity-state/state.ts
new file mode 100644
index 000000000..6100221d1
--- /dev/null
+++ b/packages/widgets/src/smart-home/entity-state/state.ts
@@ -0,0 +1,11 @@
+export interface LocalizedEntityStates {
+ on: string;
+ off: string;
+ unavailable: string;
+ unknown: string;
+}
+
+export const getEntityStateLabel = (state: string | undefined, labels: LocalizedEntityStates) => {
+ if (!state) return "—";
+ return Object.hasOwn(labels, state) ? labels[state as keyof LocalizedEntityStates] : state;
+};
diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx
index 4fa6fdda7..40e5a3487 100644
--- a/packages/widgets/src/smart-home/execute-automation/component.tsx
+++ b/packages/widgets/src/smart-home/execute-automation/component.tsx
@@ -21,6 +21,7 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
+import { isSmartHomeTiny } from "../layout";
export default function SmartHomeTriggerAutomationWidget({
options,
@@ -83,7 +84,7 @@ export default function SmartHomeTriggerAutomationWidget({
[canInteract, handleClick, integrationId, isPending, options.automationId, options.displayName],
);
- const isTiny = width < 128 || height < 96;
+ const isTiny = isSmartHomeTiny(width, height);
return (
diff --git a/packages/widgets/src/smart-home/layout.spec.ts b/packages/widgets/src/smart-home/layout.spec.ts
new file mode 100644
index 000000000..e9643d677
--- /dev/null
+++ b/packages/widgets/src/smart-home/layout.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "vitest";
+
+import { isSmartHomeTiny } from "./layout";
+
+describe("smart home layout", () => {
+ test.each([
+ { width: 127, height: 96, expected: true },
+ { width: 128, height: 95, expected: true },
+ { width: 128, height: 96, expected: false },
+ { width: 256, height: 192, expected: false },
+ ])("classifies $width x $height", ({ width, height, expected }) => {
+ expect(isSmartHomeTiny(width, height)).toBe(expected);
+ });
+});
diff --git a/packages/widgets/src/smart-home/layout.ts b/packages/widgets/src/smart-home/layout.ts
new file mode 100644
index 000000000..488330549
--- /dev/null
+++ b/packages/widgets/src/smart-home/layout.ts
@@ -0,0 +1 @@
+export const isSmartHomeTiny = (width: number, height: number) => width < 128 || height < 96;
diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx
index 493520d04..d88e3beca 100644
--- a/packages/widgets/src/umami/umami-content.tsx
+++ b/packages/widgets/src/umami/umami-content.tsx
@@ -9,6 +9,7 @@ import type { UmamiVisitorStats } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { UmamiEventsContent } from "./umami-events-content";
+import { getUmamiLayout } from "./umami-layout";
import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list";
import { formatTimeFrameLabel, formatXLabel, umamiQueryOptions } from "./umami-utils";
import type { TimeFrame } from "./umami-utils";
@@ -96,11 +97,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 { isDense, showXAxis, showSecondaryStats, showDetailedStats, stackAdvancedContent } = getUmamiLayout({
+ width,
+ height,
+ displayMode,
+ });
const selectedView =
viewMode === "events" ? (
@@ -181,22 +182,21 @@ export function UmamiContent({
);
- const advancedContent =
- width < 900 ? (
-
-
- {selectedView}
- {advancedLists}
-
-
- ) : (
-
-
- {selectedView}
-
- {advancedLists}
-
- );
+ const advancedContent = stackAdvancedContent ? (
+
+
+ {selectedView}
+ {advancedLists}
+
+
+ ) : (
+
+
+ {selectedView}
+
+ {advancedLists}
+
+ );
return (
diff --git a/packages/widgets/src/umami/umami-layout.spec.ts b/packages/widgets/src/umami/umami-layout.spec.ts
new file mode 100644
index 000000000..b03f5af51
--- /dev/null
+++ b/packages/widgets/src/umami/umami-layout.spec.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test } from "vitest";
+
+import { getUmamiLayout } from "./umami-layout";
+
+describe("Umami layout", () => {
+ test.each([
+ { width: 260, height: 119, property: "isDense" as const, expected: true },
+ { width: 260, height: 120, property: "isDense" as const, expected: false },
+ { width: 260, height: 139, property: "showXAxis" as const, expected: false },
+ { width: 260, height: 140, property: "showXAxis" as const, expected: true },
+ { width: 260, height: 95, property: "showSecondaryStats" as const, expected: false },
+ { width: 260, height: 96, property: "showSecondaryStats" as const, expected: true },
+ { width: 259, height: 150, property: "showDetailedStats" as const, expected: false },
+ { width: 260, height: 149, property: "showDetailedStats" as const, expected: false },
+ { width: 260, height: 150, property: "showDetailedStats" as const, expected: true },
+ ])("sets $property to $expected at $width x $height", ({ width, height, property, expected }) => {
+ expect(getUmamiLayout({ width, height, displayMode: "compact" })[property]).toBe(expected);
+ });
+
+ test("keeps advanced details visible and changes columns at 900px", () => {
+ expect(getUmamiLayout({ width: 899, height: 80, displayMode: "advanced" })).toEqual({
+ isDense: false,
+ showXAxis: true,
+ showSecondaryStats: true,
+ showDetailedStats: true,
+ stackAdvancedContent: true,
+ });
+ expect(getUmamiLayout({ width: 900, height: 80, displayMode: "advanced" }).stackAdvancedContent).toBe(false);
+ });
+});
diff --git a/packages/widgets/src/umami/umami-layout.ts b/packages/widgets/src/umami/umami-layout.ts
new file mode 100644
index 000000000..05f02140f
--- /dev/null
+++ b/packages/widgets/src/umami/umami-layout.ts
@@ -0,0 +1,16 @@
+interface UmamiLayoutInput {
+ width: number;
+ height: number;
+ displayMode: "compact" | "advanced";
+}
+
+export const getUmamiLayout = ({ width, height, displayMode }: UmamiLayoutInput) => {
+ const isAdvanced = displayMode === "advanced";
+ return {
+ isDense: !isAdvanced && height < 120,
+ showXAxis: isAdvanced || height >= 140,
+ showSecondaryStats: isAdvanced || height >= 96,
+ showDetailedStats: isAdvanced || (width >= 260 && height >= 150),
+ stackAdvancedContent: isAdvanced && width < 900,
+ };
+};
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index cd75dea8d..f58e97653 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -35,6 +35,7 @@ export default function WeatherWidget({
height,
displayMode,
}: WidgetComponentProps<"weather">) {
+ const t = useScopedI18n("widget.weather");
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -75,7 +76,7 @@ export default function WeatherWidget({
{content}
{weatherQuery.error && (
-
+
)}