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 && ( - + )}