From 1e0bf400404944a9b1ee4b734df8c0c56db70ab7 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 --- .../advanced/keyboard-shortcuts/index.mdx | 7 +- .../after-the-installation.mdx | 7 +- apps/docs/docs/management/boards/index.mdx | 5 +- apps/docs/docs/widgets/anchor-note/index.mdx | 2 +- apps/docs/docs/widgets/app/index.mdx | 7 +- apps/docs/docs/widgets/bookmarks/index.mdx | 1 + apps/docs/docs/widgets/calendar/index.mdx | 3 +- .../widgets/immich-album-carousel/index.mdx | 2 + apps/docs/docs/widgets/notebook/index.mdx | 2 +- apps/docs/docs/widgets/stock-price/index.mdx | 1 + apps/docs/docs/widgets/weather/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 +- .../components/board/items/item-content.tsx | 252 ++++----- .../board/items/item-select-modal.tsx | 5 - .../board/items/widget-context-menu.tsx | 26 +- e2e/board-advanced-interactions.spec.ts | 123 ++-- .../request-handler/src/timetable.spec.ts | 38 +- packages/request-handler/src/timetable.ts | 20 +- packages/translation/src/lang/en.json | 21 +- .../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 | 4 +- .../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 | 113 +++- packages/widgets/src/coolify/component.tsx | 25 +- .../widgets/src/coolify/instance-card.tsx | 68 ++- packages/widgets/src/coolify/resource-row.tsx | 50 +- .../widgets/src/coolify/servers-section.tsx | 15 +- .../src/coolify/single-instance-layout.tsx | 46 +- packages/widgets/src/custom-api/component.tsx | 214 +++---- .../src/dns-hole/controls/component.tsx | 24 +- .../src/dns-hole/summary/component.tsx | 31 +- packages/widgets/src/docker/component.tsx | 70 ++- 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 +- .../widgets/src/media-missing/component.tsx | 3 - .../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 + .../widgets/src/media-server/component.tsx | 46 +- .../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 | 64 ++- .../src/system-resources/history.spec.ts | 8 +- 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 +++++++-- 125 files changed, 3281 insertions(+), 1459 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/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 5d7f01cde..1f7842fd7 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -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. ::: diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index f8fe31ed2..29d56e4eb 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -191,9 +191,10 @@ When entering the edit mode, you'll get some additional buttons to work with: - **Exit edit mode**: Click this button to save your changes and exit edit mode. Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions. -Hold `Shift` while hovering a widget for a temporary advanced view; use its pin control to keep it open. You can also -focus the widget and press `Shift` + `Enter` to open it pinned. If you can change the board, right-click empty grid -space to add an item at that position. +Hold `Shift` while hovering a widget to expand an advanced preview in place while the rest of the board stays visible +but dimmed. Release `Shift` to close it. You can also focus the widget and press `Shift` + `Enter` to keep its advanced +view open until you close it. App tiles stay compact. If you can change the board, right-click empty grid space to add +an item at that position. On a touch device, touch and hold empty grid space for the same precise-placement menu. To continue, click the option "New Item": diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 02ca0e6c9..6babee071 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -179,8 +179,9 @@ This will disable the status check for apps. Widgets expose their routine controls without requiring edit mode: -- Hold `Shift` while hovering a widget for a temporary advanced view, then use its pin control to keep it open. Focus - the widget and press `Shift` + `Enter` to open it pinned, or use the maximize control on touch devices. +- Hold `Shift` while hovering a widget to expand its advanced preview in place while the other widgets remain visible + but dimmed. Release `Shift` to close it. Focus the widget and press `Shift` + `Enter` to keep the view open until you + close it, or use the maximize control on touch devices. App tiles stay compact. - Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run supported actions. Available controls follow your board and integration permissions. - If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board diff --git a/apps/docs/docs/widgets/anchor-note/index.mdx b/apps/docs/docs/widgets/anchor-note/index.mdx index bfc520c6a..522252a1a 100644 --- a/apps/docs/docs/widgets/anchor-note/index.mdx +++ b/apps/docs/docs/widgets/anchor-note/index.mdx @@ -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. diff --git a/apps/docs/docs/widgets/app/index.mdx b/apps/docs/docs/widgets/app/index.mdx index 4fdc3ff5f..dd5cef590 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -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"; app pi-hole with-status-check status-check-icon app jellyseerr with-description-tooltip - + +### 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 diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index 02de33b5a..b61f8f072 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -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 diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index 174391e18..b3ff3ec87 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -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. diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index 2975d53fe..4a0cc6eef 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -33,6 +33,8 @@ import screen from "./img/screen.png"; +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 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/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx index 2a6119240..4294c9cab 100644 --- a/apps/docs/docs/widgets/stock-price/index.mdx +++ b/apps/docs/docs/widgets/stock-price/index.mdx @@ -14,6 +14,7 @@ import { stockPriceWidget } from "."; 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 diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index 3ca81acaa..a4b84b5fa 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -14,6 +14,8 @@ import { weatherWidget } from "."; 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 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/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 832500ab7..969195d60 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,8 +1,8 @@ -import type { CSSProperties, KeyboardEvent, MutableRefObject, PointerEvent } from "react"; +import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react"; import { useEffect, useRef } from "react"; -import { ActionIcon, Badge, Box, Card, FocusTrap, Text } from "@mantine/core"; +import { ActionIcon, Badge, Box, Card } from "@mantine/core"; import { useElementSize, useViewportSize } from "@mantine/hooks"; -import { IconMaximize, IconPin, IconX } from "@tabler/icons-react"; +import { IconMaximize, IconX } from "@tabler/icons-react"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; @@ -18,7 +18,7 @@ import { WidgetError } from "@homarr/widgets/errors"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; import { useAdvancedFocus } from "../advanced-focus/context"; -import { getAdvancedFocusRect } from "../advanced-focus/geometry"; +import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; @@ -43,42 +43,43 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const board = useRequiredBoard(); const t = useI18n(); const widgetName = t(`widget.${item.kind}.name`); - const advancedDialogLabel = t("item.advancedFocus.dialog", { widget: widgetName }); + const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); const [isEditMode] = useEditMode(); const widgetStateRef = useRef | null>(null); - const { active, open, close, dismiss, pin, hover, leave } = useAdvancedFocus(); - const activeFocus = active?.itemId === item.id ? active : null; + const closeButtonRef = useRef(null); + const { active, open, close, dismiss, hover, leave } = useAdvancedFocus(); + const supportsAdvancedFocus = item.kind !== "app"; + const advancedViewId = `advanced-focus-${item.id}`; + const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; const isAdvanced = activeFocus !== null; const advancedRect = activeFocus ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) : null; + const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null; // Responsive layout changes remount grid items. Never leave their focus backdrop orphaned. useEffect(() => () => dismiss(item.id), [dismiss, item.id]); - const openPinned = () => { - if (sourceRef.current) open(item.id, sourceRef.current, true); + useEffect(() => { + if (!activeFocus?.autofocusClose || activeFocus.phase !== "visible") return; + const frame = requestAnimationFrame(() => closeButtonRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [activeFocus?.autofocusClose, activeFocus?.phase]); + + const openAdvanced = (autofocusClose = false) => { + if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose }); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.currentTarget !== event.target || event.key !== "Enter" || !event.shiftKey || isEditMode) return; + if ( + event.currentTarget !== event.target || + event.key !== "Enter" || + !event.shiftKey || + isEditMode || + !supportsAdvancedFocus + ) + return; event.preventDefault(); - openPinned(); - }; - const handleSurfacePointerDown = (event: PointerEvent) => { - if (!activeFocus || activeFocus.pinned) return; - - const target = - event.target instanceof Element - ? event.target.closest( - "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, [tabindex]:not([tabindex='-1']), [contenteditable='true']", - ) - : null; - const alreadyAutofocused = target?.hasAttribute("data-autofocus") ?? false; - if (target && !alreadyAutofocused) target.setAttribute("data-autofocus", "true"); - pin(false); - if (target && !alreadyAutofocused) { - window.setTimeout(() => target.removeAttribute("data-autofocus"), 250); - } + openAdvanced(true); }; return ( @@ -86,15 +87,21 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { sourceRef.current && hover(item.id, sourceRef.current)} - onPointerLeave={() => leave(item.id)} + tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined} + role={!isEditMode && supportsAdvancedFocus ? "group" : undefined} + aria-label={ + !isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined + } + aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined} + aria-controls={isAdvanced ? advancedViewId : undefined} + aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined} + onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined} + onPointerEnter={() => { + if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current); + }} + onPointerLeave={() => { + if (supportsAdvancedFocus) leave(item.id); + }} className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)} >
{ isAdvanced && advancedFocusClasses.surfaceHostAdvanced, )} > - - close()} > -
- {isAdvanced && ( - - {advancedDialogLabel} - - )} - {isAdvanced && ( - close()} - > - - - )} - {isAdvanced && ( - pin(event.detail === 0)} - > - - - )} - {!isEditMode && !isAdvanced && ( - - - - )} -
- - - -
-
+ + + )} + +
+ {!isEditMode && supportsAdvancedFocus && !isAdvanced && ( + openAdvanced()} + > + + + )} +
+ + + +
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index d7994d77b..04ed92b17 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -283,11 +283,6 @@ const WidgetItem = ({ {item.description} - {hasMatchingIntegration && ( - - {t("item.create.integrationAvailable")} - - )} {children} - } - onClick={() => { - if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current, true); - }} - disabled={isEditMode} - > - {t("item.advancedFocus.open")} - - + {item.kind !== "app" && ( + <> + } + onClick={() => { + if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current); + }} + disabled={isEditMode} + > + {t("item.advancedFocus.open")} + + + + )} {toggleOptions.length > 0 && ( <> {tMenu("options")} 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/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 61d6d6e50..badfef807 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1645,7 +1645,6 @@ "search": "Filter items", "addToBoard": "Add to board", "addAtPosition": "Add item here", - "integrationAvailable": "Connected integration available", "notification": { "error": { "title": "Unable to add item", @@ -1656,9 +1655,7 @@ "advancedFocus": { "open": "Open advanced view", "close": "Close advanced view", - "pin": "Keep advanced view open", - "pinned": "Advanced view is pinned", - "dialog": "{widget} advanced view" + "label": "{widget} advanced view" }, "moveResize": { "title": "Move / resize item", @@ -2298,6 +2295,12 @@ "duration": { "allDay": "All day" }, + "advanced": { + "previousMonth": "Show previous month", + "nextMonth": "Show next month", + "eventCount": "{count, plural, one {# event} other {# events}}", + "noEvents": "No events in this month" + }, "option": { "releaseType": { "label": "Radarr release type", @@ -2354,6 +2357,16 @@ } }, "currentWindSpeed": "{currentWindSpeed} {unit}", + "advanced": { + "forecast": "7-day forecast", + "day": "Day", + "conditions": "Conditions", + "temperature": "High / low", + "humidity": "Humidity", + "wind": "Wind / gusts ({unit})", + "sun": "Sunrise / sunset", + "noForecast": "No forecast data available" + }, "dailyForecast": { "sunrise": "Sunrise", "sunset": "Sunset", 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 1290408ab..fe78ea689 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -41,6 +41,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 b43ea1765..0571aafeb 100644 --- a/packages/widgets/src/beszel/_shared/tooltip.tsx +++ b/packages/widgets/src/beszel/_shared/tooltip.tsx @@ -3,6 +3,7 @@ import type { CSSProperties } from "react"; import { createPortal } from "react-dom"; import { useCallback, useEffect, useRef, useState } from "react"; +import { getDefaultZIndex } from "@mantine/core"; import dayjs from "dayjs"; import localizedFormat from "dayjs/plugin/localizedFormat"; @@ -56,6 +57,7 @@ interface PortalTooltipProps { } const MARGIN = 14; +const TOOLTIP_Z_INDEX = getDefaultZIndex("modal"); const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => { const mouseRef = useRef({ x: 0, y: 0 }); @@ -120,7 +122,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: position: "fixed", left: pos.x, top: pos.y, - zIndex: 10000, + zIndex: TOOLTIP_Z_INDEX, pointerEvents: "none", }; 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 cef9b9458..c4cee758a 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -2,9 +2,10 @@ import { useMemo, useState } from "react"; import { useParams } from "next/navigation"; -import { Box, Group, Stack, Text, useMantineTheme } from "@mantine/core"; +import { ActionIcon, Box, Group, 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"; @@ -12,12 +13,19 @@ 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 { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { setWidgetRuntimeQueries } from "../definition"; 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 { CalendarEventList } from "./calendar-event-list"; import classes from "./component.module.css"; @@ -102,9 +110,11 @@ const CalendarBase = ({ 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( @@ -114,10 +124,23 @@ 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 && ( @@ -138,7 +161,7 @@ const CalendarBase = ({ static={isEditMode} className={classes.calendar} w="100%" - h={displayMode === "advanced" ? "62%" : "100%"} + h="100%" styles={{ calendarHeaderControl: { pointerEvents: isEditMode ? "none" : undefined, @@ -192,19 +215,77 @@ const CalendarBase = ({ events={eventsForDate} disabled={isEditMode || eventsForDate.length === 0} rootWidth={width} - rootHeight={calendarHeight} + rootHeight={height} /> ); }} /> - {displayMode === "advanced" && ( - - - {new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length} - - - - )} + + ); +}; + +interface CalendarAgendaProps { + events: CalendarEvent[]; + failedIntegrations: CalendarBaseProps["failedIntegrations"]; + isEditMode: boolean; + locale: string; + month: Date; + setMonth: (date: Date) => void; +} + +const CalendarAgenda = ({ events, failedIntegrations, 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} + + + {t("eventCount", { count: events.length })} + + + + setMonth(moveCalendarMonth(month, 1))} + > + + + + + {failedIntegrations.length > 0 && } + + + + {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 1cb51de41..4cec5c1f8 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core"; +import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { useTimeAgo } from "@homarr/common"; @@ -20,10 +20,12 @@ interface InstanceCardProps { isTiny: boolean; widgetKey: string; isAdvanced: boolean; + hideFooter: boolean; } -export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced, 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}`, @@ -51,6 +53,23 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced 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 ( @@ -62,17 +81,32 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced > Coolify - + {instance.integrationName} - {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 && ( - - - 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 b2c315894..dda35b351 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}`, @@ -54,7 +62,15 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA oolify - + {displayUrl} @@ -84,21 +100,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/custom-api/component.tsx b/packages/widgets/src/custom-api/component.tsx index 580cfb455..27b35ddbd 100644 --- a/packages/widgets/src/custom-api/component.tsx +++ b/packages/widgets/src/custom-api/component.tsx @@ -35,13 +35,21 @@ import type { WidgetComponentProps } from "../definition"; const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false }); -const valueSizeMap: Record = { sm: "sm", md: "md", lg: "lg", xl: "xl" }; +const valueSizeMap: Record = { sm: "sm", md: "md", lg: "lg", xl: "xl" }; -function SingleValueDisplay({ data }: { data: Record }) { - const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg"; +interface CustomDisplayProps { + data: Record; + displayMode: "compact" | "advanced"; + width: number; + height: number; +} + +function SingleValueDisplay({ data, displayMode, width, height }: CustomDisplayProps) { + const configuredSize = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg"; + const size = displayMode === "compact" && (width < 180 || height < 96) ? "md" : configuredSize; const labelAbove = (data.labelPosition as string) === "above"; const label = data.label ? ( - + {String(data.label)} ) : null; @@ -49,7 +57,7 @@ function SingleValueDisplay({ data }: { data: Record }) { return ( {labelAbove && label} - + <Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4} ta="center" lineClamp={2} maw="100%"> {String(data.value ?? "—")} {data.unit ? ` ${data.unit}` : ""} @@ -58,53 +66,53 @@ function SingleValueDisplay({ data }: { data: Record }) { ); } -function KeyValueDisplay({ data }: { data: Record }) { +function KeyValueDisplay({ data, displayMode, width, height }: CustomDisplayProps) { const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? []; const layout = (data.layout as string) ?? "list"; - const columns = (data.columns as number) ?? 2; + const configuredColumns = Math.max(1, (data.columns as number) ?? 2); + const columns = + displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 140))); + const padding = height < 112 ? "xs" : "sm"; if (layout === "grid") { return ( - - {entries.map((entry, i) => ( - - - {String(entry.value ?? "—")} - {entry.unit ? ` ${entry.unit}` : ""} - - - {entry.label} - - - ))} - + + + {entries.map((entry, i) => ( + + + {String(entry.value ?? "—")} + {entry.unit ? ` ${entry.unit}` : ""} + + + {entry.label} + + + ))} + + ); } return ( - - {entries.map((entry, i) => ( - - - {entry.label} - - - {String(entry.value ?? "—")} - {entry.unit ? ` ${entry.unit}` : ""} - - - ))} - + + + {entries.map((entry, i) => ( + + + {entry.label} + + + {String(entry.value ?? "—")} + {entry.unit ? ` ${entry.unit}` : ""} + + + ))} + + ); } -interface CustomDisplayProps { - data: Record; - displayMode: "compact" | "advanced"; - width: number; - height: number; -} - function TableDisplay({ data, displayMode, width, height }: CustomDisplayProps) { const columns = (data.columns as string[]) ?? []; const rows = (data.rows as unknown[][]) ?? []; @@ -184,7 +192,7 @@ function StatGridCard({ {item.unit ? ` ${item.unit}` : ""} {!hideLabel && ( - + {item.label} )} @@ -194,9 +202,9 @@ function StatGridCard({ ); } -function StatGridDisplay({ data, displayMode, width }: CustomDisplayProps) { +function StatGridDisplay({ data, displayMode, width, height }: CustomDisplayProps) { const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? []; - const configuredColumns = (data.columns as number) ?? 2; + const configuredColumns = Math.max(1, (data.columns as number) ?? 2); const columns = displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 120))); const cardStyle = (data.cardStyle as string) ?? "filled"; @@ -205,7 +213,7 @@ function StatGridDisplay({ data, displayMode, width }: CustomDisplayProps) { @@ -216,7 +224,7 @@ function StatGridDisplay({ data, displayMode, width }: CustomDisplayProps) { ); } -function ProgressBarsDisplay({ data }: { data: Record }) { +function ProgressBarsDisplay({ data, height }: CustomDisplayProps) { const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? []; const showPercentage = (data.showPercentage as boolean) ?? true; const barSize = (data.barSize as string) ?? "md"; @@ -224,32 +232,34 @@ function ProgressBarsDisplay({ data }: { data: Record }) { const sizeMap: Record = { sm: 8, md: 14, lg: 22 }; return ( - - {bars.map((bar, i) => { - const max = bar.max ?? 100; - const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0; - return ( - - - - {bar.label} - - - {showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`} - {bar.max !== undefined && showPercentage - ? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})` - : ""} - - - - - ); - })} - + + + {bars.map((bar, i) => { + const max = bar.max ?? 100; + const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0; + return ( + + + + {bar.label} + + + {showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`} + {bar.max !== undefined && showPercentage + ? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})` + : ""} + + + + + ); + })} + + ); } -function StatusIndicatorDisplay({ data }: { data: Record }) { +function StatusIndicatorDisplay({ data, displayMode, width, height }: CustomDisplayProps) { const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? []; const layout = (data.layout as string) ?? "list"; const dotSize = (data.dotSize as string) ?? "md"; @@ -268,51 +278,60 @@ function StatusIndicatorDisplay({ data }: { data: Record }) { flexShrink: 0, }} /> - + {item.label} - + {item.value} ); if (layout === "grid") { + const columns = displayMode === "advanced" || width >= 280 ? 2 : 1; return ( - - {items.map(renderItem)} - + + + {items.map(renderItem)} + + ); } return ( - - {items.map(renderItem)} - + + + {items.map(renderItem)} + + ); } -function CountGridDisplay({ data }: { data: Record }) { +function CountGridDisplay({ data, displayMode, width, height }: CustomDisplayProps) { const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? []; - const columns = (data.columns as number) ?? 2; + const configuredColumns = Math.max(1, (data.columns as number) ?? 2); + const columns = + displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 120))); const valueSize = (data.valueSize as string) ?? "md"; const sizeMap: Record = { sm: "sm", md: "md", lg: "lg" }; return ( - - {items.map((item, i) => ( - - - {String(item.value ?? "—")} - {item.unit ? ` ${item.unit}` : ""} - - - {item.label} - - - ))} - + + + {items.map((item, i) => ( + + + {String(item.value ?? "—")} + {item.unit ? ` ${item.unit}` : ""} + + + {item.label} + + + ))} + + ); } @@ -328,7 +347,7 @@ function RawDisplay({ data, displayMode, height }: CustomDisplayProps) { const jsonString = JSON.stringify(data.data, null, 2); return ( - + openJsonInBrowser(data.data)}> @@ -336,7 +355,7 @@ function RawDisplay({ data, displayMode, height }: CustomDisplayProps) { - + {jsonString} @@ -345,7 +364,7 @@ function RawDisplay({ data, displayMode, height }: CustomDisplayProps) { ); } -function ActionButtonDisplay({ data }: { data: Record }) { +function ActionButtonDisplay({ data, width, height }: CustomDisplayProps) { const t = useScopedI18n("widget.customApi"); const { openConfirmModal } = useConfirmModal(); const executeMutation = clientApi.customWidget.execute.useMutation(); @@ -389,14 +408,15 @@ function ActionButtonDisplay({ data }: { data: Record }) { return (
); 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 (