feat: refine widget interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:38 +02:00
1 parent a9f1293077
commit 49ef7748ad
116 files changed
+3160 -1245

No files matched your search

@@ -12,7 +12,7 @@ Homarr has the following keyboard shortcuts:
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` while hovering a widget | Opens its temporary advanced view |
| `Shift` + `Enter` on a focused widget | Opens and pins its advanced view |
| `Shift` + `Enter` on a focused widget | Opens its advanced view until it is closed |
| `Esc` | Closes an open advanced view or board menu |
| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell |
@@ -20,8 +20,9 @@ Homarr has the following keyboard shortcuts:
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS.
Advanced view and precise placement are available outside edit mode. Precise placement still requires permission to
change the board and the right-click interaction preference to be enabled.
Advanced view and precise placement are available outside edit mode. App tiles stay compact and do not have an
advanced view. Precise placement still requires permission to change the board and the right-click interaction
preference to be enabled.
:::
+1 -1
View File
@@ -35,7 +35,7 @@ import anchorNoteWidgetScreenshot from "./img/widget.png";
### Interactions
- Click **Edit** to start editing the selected note.
- Hover the widget, focus within it, or use a touch device to reveal **Edit**, then select it to start editing the note.
- Click **Save** to persist changes to Anchor.
- Click **Cancel** to discard unsaved changes.
+4 -3
View File
@@ -4,8 +4,6 @@ description: "Embeds an app into the board."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -27,7 +25,10 @@ import piHoleApp from "./img/pi-hole-ping-icon.png";
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
</div>
<WidgetInteractionGuide />
### Interactions
App tiles stay compact and open their configured destination directly. Right-click a tile to inspect its status or change its settings when the board and your preferences allow it.
### Adding the widget
@@ -15,6 +15,7 @@ import { bookmarksWidget } from ".";
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
### Screenshots
+1 -2
View File
@@ -72,8 +72,7 @@ import withoutIntegrations from "./img/without-integrations.png";
### Interactions
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
- The advanced view replaces the month grid with a grouped event list. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
<WidgetInteractionGuide />
@@ -33,6 +33,8 @@ import screen from "./img/screen.png";
<WidgetInteractionGuide />
Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view groups previous, pause or resume, and next together and adds a thumbnail strip for quick navigation.
### Adding the widget
<AddingWidget />
+1 -1
View File
@@ -38,7 +38,7 @@ import editOn from "./img/edit.png";
### Editing
When the permissions of the user allows it, you can enter the editing of the text by clicking the following button in the top right.
When your permissions allow it, hover the widget, focus within it, or use a touch device to reveal the edit button in the top right. You can also double-click the document to edit it directly.
![](./img/notebook-enter-editing.webp)
@@ -14,6 +14,7 @@ import { stockPriceWidget } from ".";
<WidgetHeader widget={stockPriceWidget} categories={["Finance", "Market"]} />
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
The compact view prioritizes the symbol, current price, and trend. Company name, change, and time range appear only when both the widget width and height can support them; the advanced view always shows the complete summary.
### Screenshots
+2
View File
@@ -14,6 +14,8 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
The Weather Widget uses your location to display the current, highest and lowest temperature.
The advanced view always combines current conditions with the full seven-day forecast, including humidity, wind,
gusts, sunrise and sunset. Both views reuse the same cached Open Meteo response.
### Screenshots
@@ -1,11 +1,11 @@
import { IconArrowsMaximize, IconMouse2, IconPlus } from "@tabler/icons-react";
import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react";
const interactions = [
{
icon: IconArrowsMaximize,
title: "Advanced view",
description:
"Hold Shift while hovering (then pin it), press Shift + Enter, or choose Open advanced view from the widget menu.",
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
},
{
icon: IconMouse2,
@@ -19,6 +19,12 @@ const interactions = [
description:
"With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.",
},
{
icon: IconLayoutGrid,
title: "Adaptive detail",
description:
"Resize a widget to tune its density. Compact views keep primary status visible, then reveal details and controls as space or intent allows.",
},
] as const;
export const WidgetInteractionGuide = () => (
@@ -30,7 +36,7 @@ export const WidgetInteractionGuide = () => (
Hide shortcuts
</span>
</summary>
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-3 dark:border-[#333]">
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 xl:grid-cols-4 dark:border-[#333]">
{interactions.map(({ icon: Icon, title, description }) => (
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
@@ -40,8 +46,8 @@ export const WidgetInteractionGuide = () => (
))}
</div>
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. Touch devices expose a maximize control; board actions
respect your permissions and right-click preference.
Advanced view reuses the widget&apos;s live data and cache. The rest of the board stays visible but dimmed. Touch
devices expose a maximize control; app tiles stay compact.
</p>
</details>
);
@@ -1,28 +1,18 @@
.focusScope {
height: 100%;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 198;
background: rgba(0, 0, 0, 0.64);
backdrop-filter: blur(6px) saturate(0.75);
touch-action: none;
pointer-events: none;
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.surface {
position: fixed;
z-index: 199;
z-index: 1;
pointer-events: auto;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
box-shadow: var(--mantine-shadow-md);
overscroll-behavior: contain;
animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
padding-top: 60px;
}
.backdropClosing {
@@ -45,7 +35,7 @@
.surfaceHostAdvanced {
position: fixed;
inset: 0;
z-index: 199;
z-index: calc(var(--mantine-z-index-modal) - 1);
width: auto;
height: auto;
pointer-events: none;
@@ -58,33 +48,8 @@
pointer-events: none;
}
.surfaceControlsAdvanced {
right: 0;
bottom: auto;
height: 60px;
background: color-mix(in srgb, var(--mantine-color-body) 92%, transparent);
border-bottom: 1px solid var(--mantine-color-default-border);
}
.surfaceTitle {
position: absolute;
top: 20px;
right: 120px;
left: 16px;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
pointer-events: auto;
}
.pinButton {
position: absolute;
top: 8px;
right: 60px;
position: fixed;
z-index: 2;
pointer-events: auto;
}
@@ -141,8 +106,7 @@
@media (forced-colors: active) {
.backdrop {
backdrop-filter: none;
background: Canvas;
opacity: 0.8;
opacity: 0.4;
}
}
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Box, Portal, RemoveScroll } from "@mantine/core";
import { Overlay, Portal } from "@mantine/core";
import { useEditMode } from "@homarr/boards/edit-mode";
@@ -14,17 +14,21 @@ interface ActiveFocus {
itemId: string;
source: HTMLElement;
sourceRect: FocusRect;
pinned: boolean;
activation: "preview" | "manual";
autofocusClose: boolean;
phase: "visible" | "closing";
}
interface OpenAdvancedFocusOptions {
activation?: ActiveFocus["activation"];
autofocusClose?: boolean;
}
interface AdvancedFocusContextValue {
active: ActiveFocus | null;
open: (itemId: string, source: HTMLElement, pinned?: boolean) => void;
open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
pin: (autofocusClose?: boolean) => void;
hover: (itemId: string, source: HTMLElement) => void;
leave: (itemId: string) => void;
}
@@ -64,7 +68,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
}, []);
const open = useCallback(
(itemId: string, source: HTMLElement, pinned = false) => {
(itemId: string, source: HTMLElement, options: OpenAdvancedFocusOptions = {}) => {
if (isEditMode) return;
cancelHoverTimer();
cancelCloseTimer();
@@ -73,8 +77,8 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
itemId,
source,
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
pinned,
autofocusClose: pinned,
activation: options.activation ?? "manual",
autofocusClose: options.autofocusClose ?? false,
phase: "visible",
});
},
@@ -101,15 +105,23 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
cancelHoverTimer();
const hovered = hoveredRef.current;
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS);
hoverTimerRef.current = setTimeout(
() => open(hovered.itemId, hovered.source, { activation: "preview" }),
HOVER_DELAY_MS,
);
}, [cancelHoverTimer, open]);
const hover = useCallback(
(itemId: string, source: HTMLElement) => {
hoveredRef.current = { itemId, source };
const current = activeRef.current;
if (current?.activation === "preview" && current.itemId !== itemId) {
cancelCloseTimer();
updateActive(null);
}
startHoverTimer();
},
[startHoverTimer],
[cancelCloseTimer, startHoverTimer, updateActive],
);
const leave = useCallback(
@@ -130,15 +142,6 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
[cancelCloseTimer, cancelHoverTimer, updateActive],
);
const pin = useCallback(
(autofocusClose = false) => {
const current = activeRef.current;
if (!current || current.pinned || current.phase === "closing") return;
updateActive({ ...current, pinned: true, autofocusClose });
},
[updateActive],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && activeRef.current) {
@@ -151,7 +154,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
}
if (isEditMode) return;
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
if (activeRef.current?.pinned) return;
if (activeRef.current?.activation === "manual") return;
shiftHeldRef.current = true;
startHoverTimer();
};
@@ -159,12 +162,12 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
if (event.key !== "Shift") return;
shiftHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current && !activeRef.current.pinned) close(false);
if (activeRef.current?.activation === "preview") close(false);
};
const handleBlur = () => {
shiftHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current && !activeRef.current.pinned) close(false);
if (activeRef.current?.activation === "preview") close(false);
};
window.addEventListener("keydown", handleKeyDown);
@@ -184,20 +187,22 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
}, [close, isEditMode]);
const value = useMemo(
() => ({ active, open, close, dismiss, pin, hover, leave }),
[active, close, dismiss, hover, leave, open, pin],
() => ({ active, open, close, dismiss, hover, leave }),
[active, close, dismiss, hover, leave, open],
);
return (
<AdvancedFocusContext.Provider value={value}>
<RemoveScroll enabled={active !== null} className={classes.focusScope}>
{children}
</RemoveScroll>
{children}
{active && (
<Portal>
<Box
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="calc(var(--mantine-z-index-modal) - 2)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
onPointerDown={() => close()}
aria-hidden
/>
</Portal>
@@ -5,7 +5,7 @@ import { isEscapeOwnedByNestedOverlay } from "./escape";
describe("isEscapeOwnedByNestedOverlay", () => {
test("allows Escape from the advanced surface itself", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
surface.setAttribute("role", "region");
const button = document.createElement("button");
surface.append(button);
@@ -14,7 +14,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
test("leaves Escape to a nested Mantine modal", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
surface.setAttribute("role", "region");
const modal = document.createElement("section");
modal.setAttribute("role", "dialog");
const input = document.createElement("input");
@@ -25,7 +25,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
surface.setAttribute("role", "region");
const overlay = document.createElement("div");
overlay.setAttribute("role", role);
const target = document.createElement("button");
@@ -36,7 +36,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
surface.setAttribute("role", "region");
const target = document.createElement("button");
target.setAttribute("data-mantine-stop-propagation", "true");
surface.append(target);
@@ -46,7 +46,7 @@ describe("isEscapeOwnedByNestedOverlay", () => {
test("does not mistake the compact widget source for a nested overlay", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
surface.setAttribute("role", "region");
const source = document.createElement("button");
expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
@@ -1,15 +1,14 @@
import { describe, expect, it } from "vitest";
import { getAdvancedFocusRect } from "./geometry";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "./geometry";
describe("getAdvancedFocusRect", () => {
it("expands a widget and keeps it inside the viewport", () => {
const rect = getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 });
it("expands around the widget instead of centering a modal", () => {
const rect = getAdvancedFocusRect({ left: 450, top: 300, width: 300, height: 200 }, { width: 1200, height: 800 });
expect(rect.left).toBe(120);
expect(rect.top).toBeCloseTo(118.6);
expect(rect.width).toBe(960);
expect(rect.height).toBeCloseTo(622.8);
expect(rect).toEqual({ left: 200, top: 120, width: 800, height: 560 });
expect(rect.left + rect.width / 2).toBe(600);
expect(rect.top + rect.height / 2).toBe(400);
});
it("uses the available viewport on small screens", () => {
@@ -22,9 +21,34 @@ describe("getAdvancedFocusRect", () => {
});
it("keeps controls below the fixed board header on short screens", () => {
const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 });
const rect = getAdvancedFocusRect({ left: 1200, top: 700, width: 500, height: 500 }, { width: 1366, height: 768 });
expect(rect.top).toBeGreaterThanOrEqual(84);
expect(rect.top + rect.height).toBeLessThanOrEqual(744);
expect(rect.left).toBeGreaterThanOrEqual(16);
expect(rect.left + rect.width).toBeLessThanOrEqual(1350);
expect(rect.top).toBeGreaterThanOrEqual(76);
expect(rect.top + rect.height).toBeLessThanOrEqual(752);
});
});
describe("getAdvancedFocusClosePosition", () => {
it("keeps the close control outside the surface when the right side has room", () => {
expect(getAdvancedFocusClosePosition({ left: 16, top: 76, width: 800, height: 560 }, 1280)).toEqual({
left: 824,
top: 76,
});
});
it("uses the left side when the surface is anchored to the right edge", () => {
expect(getAdvancedFocusClosePosition({ left: 464, top: 76, width: 800, height: 560 }, 1280)).toEqual({
left: 412,
top: 76,
});
});
it("falls back to the inside corner on narrow screens", () => {
expect(getAdvancedFocusClosePosition({ left: 8, top: 68, width: 374, height: 624 }, 390)).toEqual({
left: 330,
top: 76,
});
});
});
@@ -7,22 +7,48 @@ export interface FocusRect {
height: number;
}
export interface FocusClosePosition {
left: number;
top: number;
}
const closeButtonSize = 44;
const closeButtonGap = 8;
export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
const margin = viewport.width < 640 ? 8 : 24;
const margin = viewport.width < 640 ? 8 : 16;
const availableWidth = Math.max(0, viewport.width - margin * 2);
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(960, availableWidth * 0.9);
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(720, availableHeight * 0.9);
const preferredWidth = viewport.width < 640 ? availableWidth : Math.min(800, availableWidth);
const preferredHeight = viewport.width < 640 ? availableHeight : Math.min(560, availableHeight);
const width = Math.min(availableWidth, Math.max(source.width, preferredWidth));
const height = Math.min(availableHeight, Math.max(source.height, preferredHeight));
const minimumTop = appShellHeaderHeight + margin;
const maximumLeft = Math.max(margin, viewport.width - margin - width);
const maximumTop = Math.max(minimumTop, viewport.height - margin - height);
return {
left: Math.max(margin, (viewport.width - width) / 2),
top: Math.max(
appShellHeaderHeight + margin,
appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2,
),
left: Math.min(maximumLeft, Math.max(margin, source.left - (width - source.width) / 2)),
top: Math.min(maximumTop, Math.max(minimumTop, source.top - (height - source.height) / 2)),
width,
height,
};
};
export const getAdvancedFocusClosePosition = (surface: FocusRect, viewportWidth: number): FocusClosePosition => {
const outsideOffset = closeButtonSize + closeButtonGap;
const surfaceRight = surface.left + surface.width;
if (viewportWidth - surfaceRight >= outsideOffset) {
return { left: surfaceRight + closeButtonGap, top: surface.top };
}
if (surface.left >= outsideOffset) {
return { left: surface.left - outsideOffset, top: surface.top };
}
return {
left: Math.max(0, Math.min(viewportWidth - closeButtonSize, surfaceRight - outsideOffset)),
top: surface.top + closeButtonGap,
};
};
+87 -36
View File
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, test } from "vitest";
import { hashPasswordAsync } from "../packages/auth/security";
import {
apps,
boardUserPermissions,
boards,
itemLayouts,
@@ -24,6 +25,7 @@ const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
const boardName = "advanced-interactions-e2e";
const emptyCellCenterXRatio = 7.5 / 12;
describe("Board advanced interactions", () => {
let browser: Browser;
@@ -67,7 +69,10 @@ describe("Board advanced interactions", () => {
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const compactSurface = widget.locator(".clock-wrapper");
const advancedDialog = page.getByRole("dialog", { name: "Date and time advanced view" });
const advancedSurface = page.getByRole("region", { name: "Date and time advanced view" });
const iframeAdvancedSurface = page.getByRole("region", { name: "iFrame advanced view" });
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
const otherWidget = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
await compactSurface.evaluate((element) => {
element.setAttribute("data-lifecycle-probe", "same-instance");
@@ -75,43 +80,60 @@ describe("Board advanced interactions", () => {
await widget.focus();
await page.keyboard.down("Shift");
await widget.hover();
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
await expect(advancedDialog).toHaveCSS("animation-name", "none");
const advancedBounds = await advancedDialog.boundingBox();
expect(advancedBounds?.width).toBeGreaterThanOrEqual(900);
await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(advancedSurface).toBeVisible({ timeout: 2_000 });
await expect(advancedSurface).toHaveCSS("animation-name", "none");
const compactBounds = await widget.boundingBox();
const advancedBounds = await advancedSurface.boundingBox();
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
await expect(advancedSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toBeVisible();
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
await expect(otherWidget).toBeVisible();
await expect(widget).toBeFocused();
await advancedSurface.click({ button: "right", position: { x: 80, y: 80 } });
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
await expect(portalledMenuItem).toBeVisible();
await portalledMenuItem.hover();
await expect(advancedSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(portalledMenuItem).toBeHidden();
await expect(advancedSurface).toBeVisible();
await otherWidget.hover();
await expect(advancedSurface).toBeHidden();
await expect(iframeAdvancedSurface).toBeVisible({ timeout: 2_000 });
await page.keyboard.up("Shift");
await expect(advancedDialog).toBeHidden();
await expect(iframeAdvancedSurface).toBeHidden();
await expect(dimmingOverlay).toBeHidden();
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(widget).toBeFocused();
await page.keyboard.down("Shift");
await widget.hover();
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
await page.getByRole("button", { name: "Keep advanced view open" }).click();
const pinnedButton = page.getByRole("button", { name: "Advanced view is pinned" });
await expect(pinnedButton).toBeFocused();
await page.keyboard.up("Shift");
await expect(advancedDialog).toBeVisible();
await page.keyboard.press("Shift+Tab");
await expect(advancedDialog).toBeVisible();
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(advancedDialog).toBeHidden();
await expect(widget).toBeFocused();
await page.keyboard.press("Shift+Enter");
await expect(advancedDialog).toBeVisible();
await expect(advancedSurface).toBeVisible();
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
0,
);
await page.keyboard.press("Escape");
await expect(advancedDialog).toBeHidden();
await expect(advancedSurface).toBeHidden();
await expect(widget).toBeFocused();
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(advancedDialog).toBeVisible();
await expect(advancedSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(advancedDialog).toBeHidden();
await expect(advancedSurface).toBeHidden();
const appWidget = page.locator(".grid-stack-item[data-kind='app'] > .grid-stack-item-content").first();
await expect(appWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await appWidget.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.down("Shift");
await appWidget.hover();
await expect(advancedSurface).toBeHidden();
await page.keyboard.up("Shift");
} finally {
await context.close();
}
@@ -131,7 +153,7 @@ describe("Board advanced interactions", () => {
await iframeSlot.focus();
await page.keyboard.press("Shift+Enter");
await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeVisible();
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeVisible();
await expect(frame.locator("#state")).toHaveValue("changed");
await page.keyboard.press("Escape");
await expect(frame.locator("#state")).toHaveValue("changed");
@@ -139,7 +161,7 @@ describe("Board advanced interactions", () => {
await iframeSlot.focus();
await page.keyboard.press("Shift+Enter");
await page.setViewportSize({ width: 800, height: 900 });
await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeHidden();
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeHidden();
await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
} finally {
await context.close();
@@ -163,13 +185,18 @@ describe("Board advanced interactions", () => {
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
const dialogBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
const surfaceBounds = await page.getByRole("region", { name: "Date and time advanced view" }).boundingBox();
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
expect(dialogBounds).not.toBeNull();
expect(surfaceBounds).not.toBeNull();
expect(contentBounds).not.toBeNull();
if (dialogBounds && contentBounds) expect(contentBounds.y).toBeGreaterThanOrEqual(dialogBounds.y + 60);
if (surfaceBounds && contentBounds) {
expect(contentBounds.y - surfaceBounds.y).toBeLessThan(32);
expect(bounds.x >= surfaceBounds.x + surfaceBounds.width || bounds.x + bounds.width <= surfaceBounds.x).toBe(
true,
);
}
await closeButton.click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeHidden();
} finally {
await context.close();
}
@@ -232,7 +259,7 @@ describe("Board advanced interactions", () => {
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
@@ -273,7 +300,7 @@ const rightClickEmptyGridSpaceAsync = async (grid: Locator) => {
await grid.click({
button: "right",
position: { x: bounds.width - 16, y: Math.min(24, bounds.height / 2) },
position: { x: bounds.width * emptyCellCenterXRatio, y: Math.min(24, bounds.height / 2) },
});
};
@@ -282,7 +309,7 @@ const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
expect(bounds).not.toBeNull();
if (!bounds) return;
const clientX = bounds.x + bounds.width - 16;
const clientX = bounds.x + bounds.width * emptyCellCenterXRatio;
const clientY = bounds.y + Math.min(24, bounds.height / 2);
await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
await new Promise((resolve) => setTimeout(resolve, 800));
@@ -296,8 +323,16 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
const sectionId = createId();
const clockItemId = createId();
const iframeItemId = createId();
const appItemId = createId();
const appId = createId();
await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
await db.insert(apps).values({
id: appId,
name: "Example app",
iconUrl: "/favicon.ico",
href: "https://example.com",
});
await db.insert(layouts).values([
{ id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
{ id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
@@ -318,6 +353,13 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
{
id: appItemId,
kind: "app",
boardId,
options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
]);
await db.insert(itemLayouts).values(
[baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
@@ -334,11 +376,20 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
itemId: iframeItemId,
sectionId,
layoutId,
xOffset: 4,
xOffset: 8,
yOffset: 0,
width: 4,
height: 3,
},
{
itemId: appItemId,
sectionId,
layoutId,
xOffset: 4,
yOffset: 0,
width: 3,
height: 3,
},
]),
);
await db
+37 -1
View File
@@ -14,7 +14,8 @@ vi.mock("@homarr/core/infrastructure/http", () => ({
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
const mockCloseDispatcher = vi.fn();
const mockDispatcher = { close: mockCloseDispatcher };
const mockDestroyDispatcher = vi.fn().mockResolvedValue(undefined);
const mockDispatcher = { close: mockCloseDispatcher, destroy: mockDestroyDispatcher };
beforeEach(() => {
vi.clearAllMocks();
@@ -156,6 +157,41 @@ describe("timetable response bounds", () => {
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
});
it("destroys the pinned station dispatcher when response handling fails", async () => {
mockFetch.mockResolvedValue(new Response("not json"));
await expect(
timetableSearchStationsRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
query: "invalid-stations",
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
})
.getDataAsync(),
).rejects.toThrow();
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
expect(mockCloseDispatcher).not.toHaveBeenCalled();
});
it("destroys the pinned timetable dispatcher when response handling fails", async () => {
mockFetch.mockResolvedValue(new Response("not json"));
await expect(
timetableGetTimetableRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
stationId: "invalid-departures",
limit: 1,
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
})
.getDataAsync(),
).rejects.toThrow();
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
expect(mockCloseDispatcher).not.toHaveBeenCalled();
});
it("never caches more departures than requested", async () => {
mockFetch.mockResolvedValue(
new Response(
+16 -4
View File
@@ -124,9 +124,10 @@ const searchStationsAsync = async (
query: string,
pinnedAddresses?: TimetableResolvedAddress[],
): Promise<Station[]> => {
const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
try {
const response = await fetchWithTrustedCertificatesAsync(
response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
{ ...timetableFetchOptions, dispatcher },
);
@@ -138,6 +139,11 @@ const searchStationsAsync = async (
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
.filter((item) => item !== null);
} catch (error) {
if (response?.body) await response.body.cancel().catch(() => {});
await dispatcher?.destroy().catch(() => {});
dispatcher = undefined;
throw error;
} finally {
await dispatcher?.close();
}
@@ -161,9 +167,10 @@ const getTimetableAsync = async (
minute: "2-digit",
hour12: false,
}).format(now);
const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
try {
const response = await fetchWithTrustedCertificatesAsync(
response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
stop: options.stationId,
limit: options.limit,
@@ -205,6 +212,11 @@ const getTimetableAsync = async (
};
}),
};
} catch (error) {
if (response?.body) await response.body.cancel().catch(() => {});
await dispatcher?.destroy().catch(() => {});
dispatcher = undefined;
throw error;
} finally {
await dispatcher?.close();
}
@@ -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;
}
}
@@ -294,9 +294,6 @@ const CardShell = ({
p="xs"
h={CARD_HEIGHT[density]}
>
{item.imageUrl && (
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
)}
<div className={classes.content}>{children}</div>
</Paper>
);
@@ -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={{
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more