From 842e86012d239318a9804d9e065b2f0faf5c367c Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 14:12:18 +0200 Subject: [PATCH] feat: refine widget interactions --- apps/docs/docs/widgets/notebook/index.mdx | 2 +- .../src/components/widgets/interactions.tsx | 16 +- .../advanced-focus/advanced-focus.module.css | 50 +- .../board/advanced-focus/context.tsx | 61 +- .../board/advanced-focus/escape.spec.ts | 10 +- .../board/advanced-focus/geometry.spec.ts | 44 +- .../board/advanced-focus/geometry.ts | 42 +- e2e/board-advanced-interactions.spec.ts | 123 ++-- .../request-handler/src/timetable.spec.ts | 38 +- packages/request-handler/src/timetable.ts | 20 +- .../widgets/src/anchor-note/anchor-note.css | 28 + .../widgets/src/anchor-note/component.tsx | 43 +- packages/widgets/src/app/component.tsx | 24 +- .../src/archive-team-warrior/component.tsx | 49 +- .../src/archive-team-warrior/layout.spec.ts | 23 + .../src/audio-stats/audio-stats-content.tsx | 22 +- .../audio-stats/audio-stats-shared.spec.ts | 31 +- .../widgets/src/audio-stats/component.tsx | 1 + packages/widgets/src/audio-stats/shared.ts | 17 + packages/widgets/src/bazarr/component.tsx | 4 +- .../widgets/src/beszel-alerts/component.tsx | 92 +-- .../beszel-system-grid/component.module.css | 15 +- .../src/beszel-system-grid/component.tsx | 8 +- .../beszel-system-stats/component.module.css | 16 +- .../widgets/src/beszel/_shared/tooltip.tsx | 15 +- .../widgets/src/bookmarks/bookmark.module.css | 10 + packages/widgets/src/bookmarks/component.tsx | 151 +++-- packages/widgets/src/bookmarks/layout.spec.ts | 18 +- .../src/calendar/calendar-event-list.tsx | 19 +- .../widgets/src/calendar/calendar-events.ts | 3 + .../widgets/src/calendar/calendar.spec.ts | 9 +- packages/widgets/src/calendar/component.tsx | 218 ++++++-- packages/widgets/src/coolify/component.tsx | 25 +- .../widgets/src/coolify/instance-card.tsx | 125 +++-- packages/widgets/src/coolify/resource-row.tsx | 50 +- .../widgets/src/coolify/servers-section.tsx | 15 +- .../src/coolify/single-instance-layout.tsx | 46 +- .../src/dns-hole/controls/component.tsx | 24 +- .../src/dns-hole/summary/component.tsx | 31 +- packages/widgets/src/docker/layout.spec.ts | 23 +- packages/widgets/src/docker/layout.ts | 14 +- packages/widgets/src/firewall/component.tsx | 70 ++- .../widgets/src/firewall/firewall-menu.tsx | 6 +- .../widgets/src/firewall/firewall-version.tsx | 5 +- .../cluster/cluster-health.tsx | 2 +- .../system-health.module.css | 32 ++ .../src/health-monitoring/system-health.tsx | 17 +- .../album-carousel/component.module.css | 49 +- .../src/immich/album-carousel/component.tsx | 57 +- .../immich/server-stats/component.module.css | 8 + .../src/immich/server-stats/component.tsx | 53 +- .../src/immich/server-stats/layout.spec.ts | 15 + .../src/indexer-manager/component.module.css | 41 ++ .../widgets/src/indexer-manager/component.tsx | 74 +-- .../src/media-missing/component.module.css | 30 +- .../src/media-releases/component.module.css | 24 + .../widgets/src/media-releases/component.tsx | 71 ++- .../media-requests/list/component.module.css | 45 ++ .../src/media-requests/list/component.tsx | 149 ++--- .../media-requests/stats/component.module.css | 22 + .../src/media-requests/stats/component.tsx | 22 +- .../src/media-server/component.module.css | 13 + .../src/media-transcoding/component.tsx | 7 +- .../media-transcoding/health-check-status.tsx | 22 +- .../src/minecraft/server-status/component.tsx | 29 +- .../network-status/component.tsx | 34 +- .../network-status/variants/wifi-variant.tsx | 8 +- .../network-status/variants/wired-variant.tsx | 8 +- .../network-controller/summary/component.tsx | 40 +- packages/widgets/src/notebook/notebook.css | 28 + packages/widgets/src/notebook/notebook.tsx | 6 +- .../widgets/src/notifications/component.tsx | 54 +- .../widgets/src/paperless-ngx/component.tsx | 14 +- .../src/releases/component.module.scss | 21 + packages/widgets/src/releases/component.tsx | 528 +++++++++--------- .../widgets/src/rssFeed/component.module.scss | 35 +- packages/widgets/src/rssFeed/component.tsx | 38 +- .../src/smart-home/entity-state/component.tsx | 20 +- .../execute-automation/component.tsx | 11 +- .../src/speedtest-tracker/component.tsx | 15 +- .../src/speedtest-tracker/helpers.spec.ts | 15 +- .../widgets/src/speedtest-tracker/helpers.ts | 18 + .../src/speedtest-tracker/latest-result.tsx | 2 +- .../src/speedtest-tracker/section-label.tsx | 2 +- .../src/speedtest-tracker/speed-stat-card.tsx | 55 +- packages/widgets/src/stocks/component.spec.ts | 37 +- packages/widgets/src/stocks/component.tsx | 39 +- .../widgets/src/system-disks/component.tsx | 37 +- .../system-resources/chart/common-chart.tsx | 12 + .../system-resources/chart/memory-chart.tsx | 5 +- .../chart/network-traffic.tsx | 3 +- .../src/system-resources/component.tsx | 160 +++++- .../src/system-resources/history.spec.ts | 50 +- packages/widgets/src/timetable/component.tsx | 77 ++- .../widgets/src/tracearr/component.spec.ts | 28 +- packages/widgets/src/tracearr/component.tsx | 28 +- .../src/tracearr/recent-activity-section.tsx | 2 +- .../src/tracearr/violations-section.tsx | 2 +- packages/widgets/src/traefik/component.tsx | 2 +- packages/widgets/src/umami/umami-content.tsx | 68 ++- .../src/umami/umami-events-content.tsx | 6 +- .../widgets/src/uptime-kuma/aggregate.spec.ts | 24 + .../widgets/src/uptime-kuma/component.tsx | 41 +- packages/widgets/src/vpn/component.tsx | 21 +- packages/widgets/src/vpn/vpn-card.tsx | 81 ++- .../widgets/src/weather/component.spec.ts | 14 +- packages/widgets/src/weather/component.tsx | 250 +++++++-- 107 files changed, 3144 insertions(+), 1233 deletions(-) create mode 100644 packages/widgets/src/archive-team-warrior/layout.spec.ts create mode 100644 packages/widgets/src/immich/server-stats/layout.spec.ts create mode 100644 packages/widgets/src/media-releases/component.module.css create mode 100644 packages/widgets/src/media-requests/list/component.module.css create mode 100644 packages/widgets/src/media-server/component.module.css diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index a245600aa..df4d14ec2 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -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) diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx index 80ba47b46..1328aa548 100644 --- a/apps/docs/src/components/widgets/interactions.tsx +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -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 -
+
{interactions.map(({ icon: Icon, title, description }) => (
@@ -40,8 +46,8 @@ export const WidgetInteractionGuide = () => ( ))}

- Advanced view reuses the widget'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's live data and cache. The rest of the board stays visible but dimmed. Touch + devices expose a maximize control; app tiles stay compact.

); diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css index 39965dbdf..60cc31954 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -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; } } diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 79e4ff50d..3f38bdc44 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -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 ( - - {children} - + {children} {active && ( - close()} aria-hidden /> diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts index dc123eeff..7704ef8be 100644 --- a/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts +++ b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts @@ -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); diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts index c769e7fbc..1617a4858 100644 --- a/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts @@ -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, + }); }); }); diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.ts index 086cfd622..e8859a5a8 100644 --- a/apps/nextjs/src/components/board/advanced-focus/geometry.ts +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.ts @@ -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, + }; +}; diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index 417f913b3..eabb0a33f 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -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 diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts index 54bd68645..ea39392ab 100644 --- a/packages/request-handler/src/timetable.spec.ts +++ b/packages/request-handler/src/timetable.spec.ts @@ -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( diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index 2ae4eb449..27f549ac5 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -124,9 +124,10 @@ const searchStationsAsync = async ( query: string, pinnedAddresses?: TimetableResolvedAddress[], ): Promise => { - const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined; + let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined; + let response: Awaited> | 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> | 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(); } diff --git a/packages/widgets/src/anchor-note/anchor-note.css b/packages/widgets/src/anchor-note/anchor-note.css index c28655e87..d107b2a77 100644 --- a/packages/widgets/src/anchor-note/anchor-note.css +++ b/packages/widgets/src/anchor-note/anchor-note.css @@ -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; diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 6667189b0..9268bf6f4 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -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 ; return ( - + {isEditing ? ( @@ -335,22 +336,30 @@ const AnchorNoteWidgetContent = ({ )} - - {isEditing ? ( - <> - + + + ) : isAdvanced ? ( + - - - ) : ( - - )} - + ) : ( + + + + + + )} + + )} {isEditing || isAdvanced ? (
) { +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 ; 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 } }} > e.preventDefault() : undefined} > - {(options.showTitle || isAdvanced) && ( + {options.showTitle && ( )} - {(options.descriptionDisplayMode === "normal" || isAdvanced) && ( + {options.descriptionDisplayMode === "normal" && ( {app.description?.split("\n").map((line, index) => ( @@ -110,11 +109,6 @@ export default function AppWidget({ options, isEditMode, height, width, displayM ))} )} - {isAdvanced && app.href && ( - - {getDisplayHost(app.href)} - - )} { - try { - return new URL(href).host; - } catch { - return href; - } -}; diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index e6b4f878d..83cf74289 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -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 ( - + @@ -69,7 +76,7 @@ const ArchiveTeamWarriorWidgetContent = ({ - {options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && ( + {options.showBroadcastMessage && status.broadcastMessage && layout.showBroadcast && ( {status.broadcastMessage} @@ -77,17 +84,16 @@ const ArchiveTeamWarriorWidgetContent = ({ )} - - - - - + + {countMetrics.map((metric) => ( + + ))} - {status.bandwidth && ( - - - + {status.bandwidth && layout.showBandwidth && ( + + + )} {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 }) => ( - + {value} - + {label} ); +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"; diff --git a/packages/widgets/src/archive-team-warrior/layout.spec.ts b/packages/widgets/src/archive-team-warrior/layout.spec.ts new file mode 100644 index 000000000..213c5bd11 --- /dev/null +++ b/packages/widgets/src/archive-team-warrior/layout.spec.ts @@ -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, + }); + }); +}); diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index b6824a1fe..3592744a4 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -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 (
diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts index 406562f66..c4484ac12 100644 --- a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts +++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts @@ -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"); + }); +}); diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 0504cce25..338d84027 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -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; diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts index d4b79c72c..15e6b8a12 100644 --- a/packages/widgets/src/audio-stats/shared.ts +++ b/packages/widgets/src/audio-stats/shared.ts @@ -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 => { diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 5e4d32ffd..3538ecc2f 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -89,7 +89,9 @@ export default function BazarrWidget({
{value} - {t(statKey)} + + {t(statKey)} +
); })} diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index bf0f79416..ae5d20769 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -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({ - + {t("status.triggered")} ({triggeredAlerts.length}) @@ -116,6 +130,7 @@ export default function BeszelAlertsWidget({ min={alert.min} systemName={systemNameMap[alert.system] ?? alert.system} triggered + showDescription={showAlertDescriptions} /> ))} @@ -127,7 +142,7 @@ export default function BeszelAlertsWidget({ - + {t("status.ok")} ({okAlerts.length}) @@ -139,6 +154,7 @@ export default function BeszelAlertsWidget({ min={alert.min} systemName={systemNameMap[alert.system] ?? alert.system} triggered={false} + showDescription={showAlertDescriptions} /> ))} @@ -150,7 +166,7 @@ export default function BeszelAlertsWidget({ - + {t("history")} @@ -203,6 +219,7 @@ interface AlertRowProps { min: number; systemName: string; triggered: boolean; + showDescription: boolean; } const unitSuffixMap: Record = { @@ -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 ( - - - - - - {systemName} - - - · - - - {name} - - - - {formatAlertDescription(name, value, min)} - - - + + + + + + + {systemName} + + + · + + + {name} + + + {showDescription && ( + + {description} + + )} + {!showDescription && {description}} + + + ); } diff --git a/packages/widgets/src/beszel-system-grid/component.module.css b/packages/widgets/src/beszel-system-grid/component.module.css index f70cde0e9..94c951dca 100644 --- a/packages/widgets/src/beszel-system-grid/component.module.css +++ b/packages/widgets/src/beszel-system-grid/component.module.css @@ -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; + } +} diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index ff26240b4..d7fcbb21c 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -336,6 +336,9 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick return ( diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css index 7750108f9..9d8b2087b 100644 --- a/packages/widgets/src/beszel-system-stats/component.module.css +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -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; + } +} diff --git a/packages/widgets/src/beszel/_shared/tooltip.tsx b/packages/widgets/src/beszel/_shared/tooltip.tsx index b72403fe5..a7020b2dc 100644 --- a/packages/widgets/src/beszel/_shared/tooltip.tsx +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -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 = { 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 && (
- Total + {t("total")} {formatter(total)}
)} @@ -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, }); diff --git a/packages/widgets/src/bookmarks/bookmark.module.css b/packages/widgets/src/bookmarks/bookmark.module.css index c37b78242..807b90e48 100644 --- a/packages/widgets/src/bookmarks/bookmark.module.css +++ b/packages/widgets/src/bookmarks/bookmark.module.css @@ -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); +} diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 875394ea8..87cda56b4 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -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 ( - + {options.title.length > 0 && ( - + <Title order={4} px="0.25rem" lineClamp={1}> {options.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") && ( - - )} - {displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && ( - + {displayMode !== "advanced" && ( + + + {options.layout === "grid" || options.layout === "gridHorizontal" ? ( + + ) : ( + + )} + + )} ); @@ -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 ( - + {data.map((app) => ( -
+
( { const board = useRequiredBoard(); return ( - + {data.map((app) => ( ))} - + ); }; @@ -305,7 +384,7 @@ const VerticalItem = ({ return ( {!hideTitle && ( - + {app.name} )} @@ -326,7 +405,7 @@ const VerticalItem = ({ /> )} {!hideHostname && ( - + {getBookmarkHostname(app.href)} )} @@ -365,7 +444,7 @@ const HorizontalItem = ({ )} {!(hideTitle && hideHostname) && ( <> - + {!hideTitle && ( {app.name} @@ -373,7 +452,7 @@ const HorizontalItem = ({ )} {!hideHostname && ( - + {getBookmarkHostname(app.href)} )} diff --git a/packages/widgets/src/bookmarks/layout.spec.ts b/packages/widgets/src/bookmarks/layout.spec.ts index adb35a554..8e98c3e56 100644 --- a/packages/widgets/src/bookmarks/layout.spec.ts +++ b/packages/widgets/src/bookmarks/layout.spec.ts @@ -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"); diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx index 3fadb5a98..14270322b 100644 --- a/packages/widgets/src/calendar/calendar-event-list.tsx +++ b/packages/widgets/src/calendar/calendar-event-list.tsx @@ -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) = )} )} - - - + + + {event.subTitle !== null && ( {event.subTitle} @@ -120,7 +127,7 @@ const CalendarEventRows = ({ events }: Pick) = )} - + {isAllDay(event) ? ( diff --git a/packages/widgets/src/calendar/calendar-events.ts b/packages/widgets/src/calendar/calendar-events.ts index 0b15e0098..19ddd3bd5 100644 --- a/packages/widgets/src/calendar/calendar-events.ts +++ b/packages/widgets/src/calendar/calendar-events.ts @@ -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); diff --git a/packages/widgets/src/calendar/calendar.spec.ts b/packages/widgets/src/calendar/calendar.spec.ts index 84ba92b86..d42d6f151 100644 --- a/packages/widgets/src/calendar/calendar.spec.ts +++ b/packages/widgets/src/calendar/calendar.spec.ts @@ -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, diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index d1cc45045..27190ba6d 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -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 ; + return ( + + ); } return ; @@ -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 ( + + ); + } + return ( - {failedIntegrations.length > 0 && ( + {(failedIntegrations.length > 0 || Boolean(queryError)) && ( + )} ); }} /> - {displayMode === "advanced" && ( - - - {new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length} - - - - )} + + ); +}; + +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 ( + + + + + setMonth(moveCalendarMonth(month, -1))} + > + + + + + + {monthLabel} + + + {isPending ? t("loading") : t("eventCount", { count: events.length })} + + + + setMonth(moveCalendarMonth(month, 1))} + > + + + + + + + + + + + + {isPending ? ( +
+ +
+ ) : events.length > 0 ? ( + + ) : ( + + + {t("noEvents")} + + + )} +
); }; diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 0e0a5c801..6d3f84828 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -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 ( + + + {t("action.loading")} + + + ); + } + if (instancesData.length === 1 && firstInstance) { return ( ); } @@ -73,6 +91,7 @@ function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyC isTiny={isTiny} widgetKey={widgetKey} isAdvanced={isAdvanced} + hideFooter={hideFooter} /> ))} diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx index 73faec197..aa2a61a63 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -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 ( @@ -80,19 +80,42 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }} > - + Coolify - - {instance.integrationName} - + + + {instance.integrationName} + + {isAdvanced && ( + + {t("source.url", { url: displayUrl })} + + )} + - {options.showServers && ( + {isTiny && totalResources > 0 ? ( + + + {healthyResources}/{totalResources} + + + ) : null} + {!isTiny && options.showServers && ( {onlineServers}/{instance.instanceInfo.servers.length} )} - {options.showApplications && ( + {!isTiny && options.showApplications && ( )} - {options.showServices && ( + {!isTiny && options.showServices && ( setShowIp((prev) => !prev)} /> @@ -134,18 +158,29 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced )} {options.showServices && ( - + )} - - - v{instance.instanceInfo.version} - - - {t("footer.updated", { when: relativeTime })} - - + {!hideFooter && ( + + + v{instance.instanceInfo.version} + + + {t("footer.updated", { when: relativeTime })} + + + )} ); } diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 9f6cfe1ae..4aaa4b625 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -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 {resourceUrl ? ( - + {item.name} ) : ( @@ -50,24 +57,39 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow {cleanFqdn(item.fqdn) && ( - + )} - {resourceUrl && ( - - - - )} - {logsUrl && ( - + {!isTiny && logsUrl && ( + )} - - {item.projectName ?? "-"} / {item.environmentName ?? "-"} - - {getResourceTimestamp(item, resourceType) && ( + {!isTiny && ( + + {item.projectName ?? "-"} / {item.environmentName ?? "-"} + + )} + {!isTiny && getResourceTimestamp(item, resourceType) && ( {getResourceTimestamp(item, resourceType)} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 543df1c67..55f2ad5c1 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -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({ - + {server.name} {isBuildServer ? ( @@ -110,9 +118,6 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) )} - - - {showIp ? server.ip : "***.***.***.***"} diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx index 2360f8f59..d06d7e566 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -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
- + {displayUrl}
@@ -104,21 +120,23 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA )} - - - Coolify + {!hideFooter && ( + + + Coolify + + v{instance.instanceInfo.version} + + - v{instance.instanceInfo.version} + {t("footer.updated", { when: relativeTime })} - - {t("footer.updated", { when: relativeTime })} - - + )}
); diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index e6072ff95..952cd7e04 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -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 ( + + + {t("common.action.loading")} + + + ); + } + return (