From 3edf874ca3f671268a3f31b1fe37bd06dfe5972b Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 26 Aug 2026 17:24:06 +0200 Subject: [PATCH] fix(widgets): restore compact v1 surfaces --- apps/docs/docs/widgets/app/index.mdx | 2 +- apps/docs/docs/widgets/audio-stats/index.mdx | 2 + .../docs/docs/widgets/beszel-alerts/index.mdx | 2 +- apps/docs/docs/widgets/coolify/index.mdx | 2 +- .../docs/widgets/dns-hole-controls/index.mdx | 2 +- .../docs/widgets/docker-containers/index.mdx | 2 +- apps/docs/docs/widgets/downloads/index.mdx | 11 +- apps/docs/docs/widgets/firewall/index.mdx | 2 + .../widgets/immich-server-stats/index.mdx | 2 + .../docs/widgets/indexer-manager/index.mdx | 2 + .../docs/widgets/media-releases/index.mdx | 2 +- .../docs/widgets/media-request-list/index.mdx | 2 +- .../widgets/media-request-stats/index.mdx | 2 +- apps/docs/docs/widgets/media-server/index.mdx | 2 +- .../widgets/minecraft-server-status/index.mdx | 2 +- .../network-controller-status/index.mdx | 2 +- .../network-controller-summary/index.mdx | 4 +- .../docs/docs/widgets/notifications/index.mdx | 2 +- .../docs/docs/widgets/paperless-ngx/index.mdx | 2 +- apps/docs/docs/widgets/releases/index.mdx | 4 +- apps/docs/docs/widgets/rss-feed/index.mdx | 2 +- .../widgets/smart-home-entity-state/index.mdx | 2 +- .../docs/widgets/speedtest-tracker/index.mdx | 2 +- apps/docs/docs/widgets/system-disks/index.mdx | 2 +- .../docs/widgets/system-resources/index.mdx | 2 +- apps/docs/docs/widgets/timetable/index.mdx | 2 +- apps/docs/docs/widgets/tracearr/index.mdx | 2 +- apps/docs/docs/widgets/traefik/index.mdx | 2 +- apps/docs/docs/widgets/umami/index.mdx | 2 +- apps/docs/docs/widgets/uptime-kuma/index.mdx | 2 +- .../components/board/items/item-content.tsx | 2 + .../layout/scaled-board-canvas.module.css | 4 + package.json | 1 + packages/db/package.json | 1 + .../scripts/seed-widget-regression-board.ts | 269 ++++++++++++++ packages/widgets/src/app/component.tsx | 12 +- .../src/audio-stats/audio-stats-content.tsx | 34 +- .../src/audio-stats/component.module.css | 30 +- .../widgets/src/audio-stats/component.tsx | 13 +- .../widgets/src/beszel-alerts/component.tsx | 104 +++--- packages/widgets/src/coolify/component.tsx | 10 +- .../widgets/src/coolify/instance-card.tsx | 45 +-- packages/widgets/src/coolify/resource-row.tsx | 12 +- .../src/coolify/single-instance-layout.tsx | 12 +- packages/widgets/src/definition.ts | 1 + .../src/dns-hole/controls/component.tsx | 10 +- packages/widgets/src/docker/component.tsx | 32 +- packages/widgets/src/docker/layout.spec.ts | 39 +- packages/widgets/src/docker/layout.ts | 14 +- packages/widgets/src/downloads/component.tsx | 40 +-- packages/widgets/src/firewall/component.tsx | 32 +- .../immich/server-stats/component.module.css | 14 +- .../src/immich/server-stats/component.tsx | 47 +-- .../src/immich/server-stats/layout.spec.ts | 13 +- .../src/indexer-manager/component.module.css | 4 + .../widgets/src/indexer-manager/component.tsx | 120 ++++--- .../widgets/src/media-releases/component.tsx | 58 +-- .../src/media-requests/list/component.tsx | 141 ++++---- .../src/media-requests/stats/component.tsx | 22 +- .../src/media-server/component.spec.tsx | 8 +- .../widgets/src/media-server/component.tsx | 123 ++++--- .../src/minecraft/server-status/component.tsx | 10 +- .../widgets/src/modals/widget-edit-modal.tsx | 1 + .../network-status/component.tsx | 163 ++++----- .../network-status/variants/stat-row.tsx | 15 + .../network-status/variants/wifi-variant.tsx | 25 +- .../network-status/variants/wired-variant.tsx | 25 +- .../network-controller/summary/component.tsx | 334 ++++++++---------- .../widgets/src/notifications/component.tsx | 40 ++- .../src/paperless-ngx/component.module.css | 21 +- .../widgets/src/paperless-ngx/component.tsx | 22 +- packages/widgets/src/releases/component.tsx | 2 +- .../widgets/src/rssFeed/component.module.scss | 12 + packages/widgets/src/rssFeed/component.tsx | 165 +++++---- .../src/smart-home/entity-state/component.tsx | 37 +- .../src/speedtest-tracker/averages.tsx | 15 +- .../src/speedtest-tracker/component.tsx | 29 +- .../src/speedtest-tracker/latest-result.tsx | 16 +- .../src/speedtest-tracker/recent-results.tsx | 24 +- .../src/speedtest-tracker/section-label.tsx | 10 +- .../src/speedtest-tracker/speed-stat-card.tsx | 15 +- .../widgets/src/system-disks/component.tsx | 39 +- .../chart/combined-network-traffic.tsx | 3 + .../system-resources/chart/common-chart.tsx | 57 +-- .../src/system-resources/chart/cpu-chart.tsx | 3 + .../src/system-resources/chart/gpu-chart.tsx | 3 + .../system-resources/chart/memory-chart.tsx | 3 + .../chart/network-traffic.tsx | 3 + .../src/system-resources/component.tsx | 15 +- packages/widgets/src/timetable/component.tsx | 31 +- .../widgets/src/tracearr/component.spec.ts | 28 +- packages/widgets/src/tracearr/component.tsx | 58 +-- .../src/tracearr/recent-activity-section.tsx | 6 +- .../widgets/src/tracearr/stats-section.tsx | 20 +- .../widgets/src/tracearr/streams-section.tsx | 19 +- .../src/tracearr/violations-section.tsx | 6 +- .../widgets/src/traefik/component.spec.ts | 4 +- packages/widgets/src/traefik/component.tsx | 10 +- .../widgets/src/umami/umami-layout.spec.ts | 10 +- packages/widgets/src/umami/umami-layout.ts | 6 +- .../widgets/src/uptime-kuma/aggregate.spec.ts | 24 -- .../widgets/src/uptime-kuma/component.tsx | 49 +-- 102 files changed, 1513 insertions(+), 1205 deletions(-) create mode 100644 packages/db/scripts/seed-widget-regression-board.ts diff --git a/apps/docs/docs/widgets/app/index.mdx b/apps/docs/docs/widgets/app/index.mdx index 9321c968c..3c7231fdc 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -11,7 +11,7 @@ import { appWidget } from "."; -The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. +The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions. It also supports a status check to indicate whether Homarr can reach the application or website. ### Screenshots diff --git a/apps/docs/docs/widgets/audio-stats/index.mdx b/apps/docs/docs/widgets/audio-stats/index.mdx index f79a588c3..2281e9116 100644 --- a/apps/docs/docs/widgets/audio-stats/index.mdx +++ b/apps/docs/docs/widgets/audio-stats/index.mdx @@ -23,6 +23,8 @@ This widget adapts its display based on the linked integration: Only options relevant to your linked integration are shown in the configuration panel. +Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details. + ### Supported Integrations -This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions. +This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts, descriptions, and history visible alongside triggered alerts. Alerts are updated in real time via WebSocket subscriptions. ### Screenshots diff --git a/apps/docs/docs/widgets/coolify/index.mdx b/apps/docs/docs/widgets/coolify/index.mdx index b843f4971..71b1081a0 100644 --- a/apps/docs/docs/widgets/coolify/index.mdx +++ b/apps/docs/docs/widgets/coolify/index.mdx @@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify"; -The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. +The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding. ### Supported Integrations diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index 387431190..e9b9667cb 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration. -This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget's width and height. Bulk actions keep successful results visible and report the number of failures. +This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures. ### Screenshots diff --git a/apps/docs/docs/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index e405f270d..65b3a9051 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -12,7 +12,7 @@ import { dockerContainersWidget } from "."; -This widget displays the resource usage of your Docker containers and allows you to manage them. +This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available. :::info This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges. diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index fefd416c1..a01051f0c 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png"; ### Features -- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column +- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer - **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction - **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget - **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration @@ -72,11 +72,10 @@ import filterPanelImg from "./img/filter-panel.png"; :::note -Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state -columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also -hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions, -keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available -from the footer. +Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide, +the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses +the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with +the filter panel available from the footer. The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the diff --git a/apps/docs/docs/widgets/firewall/index.mdx b/apps/docs/docs/widgets/firewall/index.mdx index aec2d89d9..856331f40 100644 --- a/apps/docs/docs/widgets/firewall/index.mdx +++ b/apps/docs/docs/widgets/firewall/index.mdx @@ -16,6 +16,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense"; +Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height. + ### Screenshots ![Firewall Widget](img/firewall_widget.png) diff --git a/apps/docs/docs/widgets/immich-server-stats/index.mdx b/apps/docs/docs/widgets/immich-server-stats/index.mdx index e2c967365..e80a2045d 100644 --- a/apps/docs/docs/widgets/immich-server-stats/index.mdx +++ b/apps/docs/docs/widgets/immich-server-stats/index.mdx @@ -15,6 +15,8 @@ import { immichServerStatsWidget } from "."; +Compact mode presents every enabled statistic as a vertical icon, label, and value card. + ### Screenshots import screen from "./img/screen.png"; diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index 2deeecd66..029eb2bf0 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -14,6 +14,8 @@ import { indexerManagerWidget } from "."; +Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together. + ### Screenshots import bigItem from "./img/big.png"; diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx index 79dfb4f17..fea6f8a71 100644 --- a/apps/docs/docs/widgets/media-releases/index.mdx +++ b/apps/docs/docs/widgets/media-releases/index.mdx @@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex"; -Provides a list of newly added media or upcoming releases from your media server. +Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata. Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails. diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 840315831..2ed31ea88 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr"; -Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. +Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions. You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days. diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index 3adfcbffb..7376bb0e4 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr"; -Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated. +Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated. ### Supported Integrations diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index a2ab91c9c..c881bc3c5 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex"; The media server widget shows active sessions across your media servers. -It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. +It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names. Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view. If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable. diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index 4df364116..c35135292 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from "."; -Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us). +Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us). ### Screenshots diff --git a/apps/docs/docs/widgets/network-controller-status/index.mdx b/apps/docs/docs/widgets/network-controller-status/index.mdx index 64ddb6ad0..a9cbfc87c 100644 --- a/apps/docs/docs/widgets/network-controller-status/index.mdx +++ b/apps/docs/docs/widgets/network-controller-status/index.mdx @@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from "."; Shows connected users and connected guest devices on wired or wireless networks. -Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible. +Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible. ### Screenshots diff --git a/apps/docs/docs/widgets/network-controller-summary/index.mdx b/apps/docs/docs/widgets/network-controller-summary/index.mdx index 3850cc25c..f90189e5e 100644 --- a/apps/docs/docs/widgets/network-controller-summary/index.mdx +++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx @@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from "."; -Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view. +Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count. ### Screenshots @@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png"; ]} /> -In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout. +In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx index 5616f297b..f68e1baee 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify"; -The notifications widget allows you to display notifications from various integrations in a unified view. +The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards. ### Screenshots diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx index a2ef00ebe..e11680f82 100644 --- a/apps/docs/docs/widgets/paperless-ngx/index.mdx +++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx @@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx"; -This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. +This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards. ### Supported Integrations diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index 88f5bc9ee..e94de9027 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -13,7 +13,7 @@ import { releasesWidget } from "."; -The releases widget enables you to track a list of repositories and see their respective latest release. +The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack. It also supports importing releases from your local docker containers. ### Screenshots @@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`. Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted. -Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links. +Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links. diff --git a/apps/docs/docs/widgets/rss-feed/index.mdx b/apps/docs/docs/widgets/rss-feed/index.mdx index cec1fcedf..eef12286d 100644 --- a/apps/docs/docs/widgets/rss-feed/index.mdx +++ b/apps/docs/docs/widgets/rss-feed/index.mdx @@ -13,7 +13,7 @@ import { rssFeedWidget } from "."; -Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. +Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop. ### Screenshots diff --git a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx index 2d12f3d71..acf6e4e8d 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant -The entity state enables you to display the current state of an entity. +The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it. This works with most entities and enables you display almost any data. It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited. diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 2308d2d95..2912946e5 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, including the latest result, historical averages, and a live chart of the last 12 hours. -Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected. +Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected. When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx index 33a2e7693..3d4e6c222 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; -This widget displays filesystem and SMART disk usage for supported server and NAS integrations. +This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar. ### Supported Integrations diff --git a/apps/docs/docs/widgets/system-resources/index.mdx b/apps/docs/docs/widgets/system-resources/index.mdx index cdc258f4c..5c68695d6 100644 --- a/apps/docs/docs/widgets/system-resources/index.mdx +++ b/apps/docs/docs/widgets/system-resources/index.mdx @@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox"; -Displays CPU, RAM and network of your host in real time +Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation. ### Screenshots diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index 1aede99cd..0855906ee 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -13,7 +13,7 @@ import { timetableWidget } from "."; -The Timetable widget provides real-time departure times for a selected station. +The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible regardless of widget height or width. ### Screenshots diff --git a/apps/docs/docs/widgets/tracearr/index.mdx b/apps/docs/docs/widgets/tracearr/index.mdx index 8d31d9d5e..4ddb47acf 100644 --- a/apps/docs/docs/widgets/tracearr/index.mdx +++ b/apps/docs/docs/widgets/tracearr/index.mdx @@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr"; -The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. +The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height. When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx index edbbba8b8..bb8393226 100644 --- a/apps/docs/docs/widgets/traefik/index.mdx +++ b/apps/docs/docs/widgets/traefik/index.mdx @@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik"; This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them. -Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected. +Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected. When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. diff --git a/apps/docs/docs/widgets/umami/index.mdx b/apps/docs/docs/widgets/umami/index.mdx index 6f2c2bb7f..eb5d5e0f9 100644 --- a/apps/docs/docs/widgets/umami/index.mdx +++ b/apps/docs/docs/widgets/umami/index.mdx @@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami"; -The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. +The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height. ### Supported Integrations diff --git a/apps/docs/docs/widgets/uptime-kuma/index.mdx b/apps/docs/docs/widgets/uptime-kuma/index.mdx index e0150a94d..6b9188b88 100644 --- a/apps/docs/docs/widgets/uptime-kuma/index.mdx +++ b/apps/docs/docs/widgets/uptime-kuma/index.mdx @@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma"; -This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. +This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height. Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches. diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index caac4cd60..e2ef8c88d 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -334,6 +334,7 @@ const LoadedBoardItemContent = ({ item={item} width={width} height={height} + displayScale={boardCanvasScale} widgetStateRef={widgetStateRef} widgetRuntimeRef={widgetRuntimeRef} displayMode={isAdvanced ? "advanced" : "compact"} @@ -436,6 +437,7 @@ interface InnerContentProps { item: SectionItem; width: number; height: number; + displayScale: number; widgetStateRef: MutableRefObject | null>; widgetRuntimeRef: WidgetRuntimeRef; displayMode: "compact" | "advanced"; diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index e0f3b6d65..91dfef8ca 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -30,11 +30,15 @@ --mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1)); --mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1)); --mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1)); + --mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1)); --mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1)); --mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1)); --mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1)); --mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1)); --mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1)); + --mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1)); + --mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1)); + --mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1)); --mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1)); --mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1)); --mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1)); diff --git a/package.json b/package.json index 347e76aa0..b4d256aaf 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dev:cli": "go -C tools/homarr-dev run -buildvcs=false .", "dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .", "dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .", + "dev:widget-regressions": "pnpm -F @homarr/db demo:widget-regressions", "dev:docs": "pnpm -F @homarr/docs dev", "docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs", "docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop", diff --git a/packages/db/package.json b/packages/db/package.json index eab91d238..61bf669b9 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,6 +23,7 @@ "build:postgresql": "esbuild migrations/postgresql/migrate.ts --bundle --platform=node --outfile=migrations/postgresql/migrate.cjs", "build:sqlite": "esbuild migrations/sqlite/migrate.ts --bundle --platform=node --outfile=migrations/sqlite/migrate.cjs", "clean": "rm -rf .turbo node_modules", + "demo:widget-regressions": "pnpm with-env tsx ./scripts/seed-widget-regression-board.ts", "format": "oxfmt .", "lint": "oxlint", "migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts", diff --git a/packages/db/scripts/seed-widget-regression-board.ts b/packages/db/scripts/seed-widget-regression-board.ts new file mode 100644 index 000000000..cb60918e9 --- /dev/null +++ b/packages/db/scripts/seed-widget-regression-board.ts @@ -0,0 +1,269 @@ +import { relative, resolve } from "node:path"; + +import { stringify } from "superjson"; + +import { createId } from "@homarr/common"; +import type { WidgetKind } from "@homarr/definitions"; + +import { db, eq } from ".."; +import { apps, boards, integrationItems, integrations, itemLayouts, items, layouts, sections, users } from "../schema"; + +const boardName = "widget-regressions"; +const truthyEnvironmentValues = new Set(["1", "true", "yes", "on"]); + +interface FixtureWidget { + kind: WidgetKind; + xOffset: number; + yOffset: number; + width: number; + height: number; + mobileHeight: number; + needsIntegration: boolean; + options?: Record; +} + +if (process.env.DB_DRIVER !== "better-sqlite3") { + throw new Error("The widget regression fixture only supports an isolated better-sqlite3 demo database"); +} + +if (!truthyEnvironmentValues.has((process.env.DEMO_MODE ?? "").toLowerCase())) { + throw new Error("The widget regression fixture requires DEMO_MODE=true"); +} + +if (truthyEnvironmentValues.has((process.env.DEMO_READ_ONLY ?? "").toLowerCase())) { + throw new Error("The widget regression fixture requires writable demo mode"); +} + +const databaseUrl = process.env.DB_URL; +if (!databaseUrl) { + throw new Error("The widget regression fixture requires DB_URL"); +} + +const resolvedDatabasePath = resolve(databaseUrl); +const pathWithinTmp = relative("/tmp", resolvedDatabasePath); +if (pathWithinTmp.length === 0 || pathWithinTmp.startsWith("..")) { + throw new Error("The widget regression fixture requires an isolated SQLite database under /tmp"); +} + +const demoUser = await db.query.users.findFirst({ + where: eq(users.name, "demo"), +}); +if (!demoUser) { + throw new Error("Demo user not found. Run the SQLite migrations and demo seed first"); +} + +const mockIntegration = await db.query.integrations.findFirst({ + where: eq(integrations.kind, "mock"), +}); +if (!mockIntegration) { + throw new Error("Mock integration not found. Start the seed with DEMO_MODE=true"); +} + +const sonarrApp = await db.query.apps.findFirst({ + where: eq(apps.name, "Sonarr"), +}); +if (!sonarrApp) { + throw new Error("Seeded Sonarr app not found"); +} + +const existingBoard = await db.query.boards.findFirst({ + where: eq(boards.name, boardName), +}); +if (existingBoard) { + await db.delete(boards).where(eq(boards.id, existingBoard.id)); +} + +const boardId = createId(); +const sectionId = createId(); +const mobileLayoutId = createId(); +const baseLayoutId = createId(); + +await db.insert(boards).values({ + id: boardId, + name: boardName, + isPublic: false, + creatorId: demoUser.id, + pageTitle: "Widget regression fixture", + primaryColor: "#fa5252", + secondaryColor: "#fd7e14", + itemRadius: "lg", +}); +await db.insert(sections).values({ + id: sectionId, + boardId, + kind: "empty", + xOffset: 0, + yOffset: 0, +}); +await db.insert(layouts).values([ + { + id: mobileLayoutId, + name: "Mobile", + boardId, + columnCount: 3, + breakpoint: 0, + role: "mobile", + }, + { + id: baseLayoutId, + name: "Base", + boardId, + columnCount: 16, + breakpoint: 768, + role: "base", + }, +]); + +const fixtureWidgets: FixtureWidget[] = [ + { + kind: "indexerManager", + xOffset: 0, + yOffset: 0, + width: 2, + height: 4, + mobileHeight: 4, + needsIntegration: true, + options: { openIndexerSiteInNewTab: true }, + }, + { + kind: "systemResources", + xOffset: 2, + yOffset: 0, + width: 4, + height: 4, + mobileHeight: 4, + needsIntegration: true, + options: { + hasShadow: true, + visibleCharts: ["cpu", "memory", "network"], + labelDisplayMode: "textWithIcon", + }, + }, + { + kind: "networkControllerStatus", + xOffset: 6, + yOffset: 0, + width: 2, + height: 2, + mobileHeight: 2, + needsIntegration: true, + options: { content: "wired" }, + }, + { + kind: "networkControllerStatus", + xOffset: 6, + yOffset: 2, + width: 2, + height: 2, + mobileHeight: 2, + needsIntegration: true, + options: { content: "wifi" }, + }, + { + kind: "audioStats", + xOffset: 8, + yOffset: 0, + width: 2, + height: 2, + mobileHeight: 3, + needsIntegration: true, + options: { + showArtists: true, + showAlbums: true, + showSongs: true, + compactMode: false, + }, + }, + { + kind: "paperlessNgx", + xOffset: 10, + yOffset: 0, + width: 2, + height: 2, + mobileHeight: 3, + needsIntegration: true, + options: { + showInboxRatio: false, + showInboxRing: false, + showDocumentsTotal: true, + showDocumentsInbox: true, + showCorrespondents: false, + showTags: false, + showDocumentTypes: true, + }, + }, + { + kind: "networkControllerSummary", + xOffset: 0, + yOffset: 4, + width: 4, + height: 2, + mobileHeight: 3, + needsIntegration: true, + }, + { + kind: "app", + xOffset: 4, + yOffset: 4, + width: 2, + height: 2, + mobileHeight: 2, + needsIntegration: false, + options: { + appId: sonarrApp.id, + openInNewTab: true, + showTitle: true, + pingEnabled: false, + }, + }, +]; + +let mobileYOffset = 0; +for (const widget of fixtureWidgets) { + const itemId = createId(); + await db.insert(items).values({ + id: itemId, + boardId, + kind: widget.kind, + options: stringify(widget.options ?? {}), + }); + await db.insert(itemLayouts).values([ + { + itemId, + sectionId, + layoutId: baseLayoutId, + xOffset: widget.xOffset, + yOffset: widget.yOffset, + width: widget.width, + height: widget.height, + }, + { + itemId, + sectionId, + layoutId: mobileLayoutId, + xOffset: 0, + yOffset: mobileYOffset, + width: 3, + height: widget.mobileHeight, + }, + ]); + mobileYOffset += widget.mobileHeight; + + if (widget.needsIntegration) { + await db.insert(integrationItems).values({ + itemId, + integrationId: mockIntegration.id, + }); + } +} + +await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, demoUser.id)); + +const insertedItems = await db.query.items.findMany({ + where: eq(items.boardId, boardId), +}); +if (insertedItems.length !== fixtureWidgets.length) { + throw new Error(`Expected ${fixtureWidgets.length} fixture widgets, inserted ${insertedItems.length}`); +} + +console.log(`Created /boards/${boardName} with ${insertedItems.length} focused regression widgets`); diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index be8b936e1..cb8c3cef6 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import { Fragment, Suspense } from "react"; -import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { IconMinus } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -59,19 +59,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget {app.name} )} - {options.descriptionDisplayMode === "normal" && !isTiny && ( + {options.descriptionDisplayMode === "normal" && ( {app.description?.split("\n").map((line, index) => ( @@ -91,7 +91,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget height: "100%", width: "100%", minWidth: "20%", - maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%", + maxWidth: isColumnLayout ? undefined : "50%", }} /> diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index f239961f5..72d9c292f 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -9,14 +9,7 @@ import { zoomCompensatedSize } from "@homarr/ui"; import classes from "./component.module.css"; import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared"; -import { - AUDIO_STATS_TRANSLATION_SCOPE, - getGridCols, - getIconSize, - prioritizeVisibleStats, - getVisibleStatLimit, - getVisibleStats, -} from "./shared"; +import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared"; const rootClassByCompact = { true: classes.rootCompact, @@ -33,13 +26,20 @@ const statTileClassByCompact = { false: "", } as const; +const statTileClassByAdvanced = { + true: classes.statTileAdvanced, + false: "", +} as const; + +const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220; + interface AudioStatsContentProps { backend: AudioStatsBackend; stats: NavidromeDashboardData | AudiobookshelfDashboardData; options: AudioStatsDisplayOptions; showAllStats?: boolean; + advanced?: boolean; width: number; - height: number; } export function AudioStatsContent({ @@ -47,15 +47,18 @@ export function AudioStatsContent({ stats, options, showAllStats = false, + advanced = false, width, - height, }: AudioStatsContentProps) { const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE); const compact = options.compactMode ?? false; - const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact); - const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact)); + const visibleStats = getVisibleStats(backend, options, stats, showAllStats); const compactKey = String(compact) as keyof typeof rootClassByCompact; - const gridCols = getGridCols(width, visibleStats.length, compact); + const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced; + let gridCols = getGridCols(width, visibleStats.length, compact); + if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) { + gridCols = 1; + } const iconSize = getIconSize(width, compact); return ( @@ -66,7 +69,10 @@ export function AudioStatsContent({ style={{ "--stat-cols": gridCols } as CSSProperties} > {visibleStats.map(({ optionKey, statKey, value, Icon }) => ( -
+
{value} {t(statKey as never)} diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css index 4a16abfb6..46813a1e0 100644 --- a/packages/widgets/src/audio-stats/component.module.css +++ b/packages/widgets/src/audio-stats/component.module.css @@ -40,10 +40,14 @@ gap: clamp(2px, 1.5cqi, 4px); padding: clamp(4px, 2cqi, 8px); border-radius: var(--mantine-radius-md); - background-color: transparent; + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); overflow: hidden; } +.statTileAdvanced { + background-color: transparent; +} + .statTileCompact { gap: clamp(1px, 0.8cqi, 2px); padding: clamp(2px, 1.2cqi, 5px); @@ -57,7 +61,11 @@ } .statLabel { - font-size: clamp(8px, 2.5cqi, 11px); + font-size: clamp( + calc(8px * var(--board-canvas-ui-scale, 1)), + calc(2.5cqi * var(--board-canvas-ui-scale, 1)), + calc(11px * var(--board-canvas-ui-scale, 1)) + ); color: var(--mantine-color-dimmed); line-height: 1.2; text-align: center; @@ -68,11 +76,19 @@ } .statTileCompact .statLabel { - font-size: clamp(7px, 2cqi, 9px); + font-size: clamp( + calc(7px * var(--board-canvas-ui-scale, 1)), + calc(2cqi * var(--board-canvas-ui-scale, 1)), + calc(9px * var(--board-canvas-ui-scale, 1)) + ); } .statValue { - font-size: clamp(13px, 5cqi, 20px); + font-size: clamp( + calc(13px * var(--board-canvas-ui-scale, 1)), + calc(5cqi * var(--board-canvas-ui-scale, 1)), + calc(20px * var(--board-canvas-ui-scale, 1)) + ); font-weight: 700; color: var(--mantine-primary-color-filled); line-height: 1.1; @@ -84,7 +100,11 @@ } .statTileCompact .statValue { - font-size: clamp(11px, 4cqi, 16px); + font-size: clamp( + calc(11px * var(--board-canvas-ui-scale, 1)), + calc(4cqi * var(--board-canvas-ui-scale, 1)), + calc(16px * var(--board-canvas-ui-scale, 1)) + ); } .emptyState { diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index f4aaca4a8..2ad3eaad5 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -17,7 +17,7 @@ export default function AudioStatsWidget({ integrationIds, options, width, - height, + displayScale = 1, displayMode = "compact", widgetRuntimeRef, }: WidgetComponentProps<"audioStats">) { @@ -39,14 +39,19 @@ export default function AudioStatsWidget({ const currentStreams = streamResults ?? []; if (!response) return ; + let responsiveWidth = width; + if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + } + const summary = ( ); if (displayMode === "compact") { diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 083d63ee6..17beda443 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -1,20 +1,7 @@ "use client"; import { useMemo } from "react"; -import { - Badge, - Box, - Center, - Divider, - Group, - Loader, - ScrollArea, - Stack, - Text, - ThemeIcon, - Tooltip, - VisuallyHidden, -} from "@mantine/core"; +import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core"; import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; import { getQueryKey } from "@trpc/react-query"; import dayjs from "dayjs"; @@ -53,7 +40,6 @@ export default function BeszelAlertsWidget({ options, integrationIds, isEditMode, - height, displayMode, widgetRuntimeRef, }: WidgetComponentProps<"beszelAlerts">) { @@ -109,9 +95,7 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260; - const showHistory = isAdvanced || (options.showHistory && height >= 360); - const showAlertDescriptions = isAdvanced || height >= 190; + const showHistory = isAdvanced || options.showHistory; if (isPending) { return ( @@ -145,7 +129,7 @@ export default function BeszelAlertsWidget({ - + {t("status.triggered")} ({triggeredAlerts.length}) @@ -158,19 +142,19 @@ export default function BeszelAlertsWidget({ systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)} integrationName={showIntegrationName ? alert.integrationName : undefined} triggered - showDescription={showAlertDescriptions} + isAdvanced={isAdvanced} /> ))} )} - {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && } + {triggeredAlerts.length > 0 && okAlerts.length > 0 && } - {showOkAlerts && okAlerts.length > 0 && ( + {okAlerts.length > 0 && ( - + {t("status.ok")} ({okAlerts.length}) @@ -183,7 +167,7 @@ export default function BeszelAlertsWidget({ systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)} integrationName={showIntegrationName ? alert.integrationName : undefined} triggered={false} - showDescription={showAlertDescriptions} + isAdvanced={isAdvanced} /> ))} @@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({ - + {t("history")} @@ -250,7 +234,7 @@ interface AlertRowProps { systemName: string; integrationName?: string; triggered: boolean; - showDescription: boolean; + isAdvanced: boolean; } const unitSuffixMap: Record = { @@ -274,43 +258,41 @@ function formatAlertDescription(name: string, value: number, min: number): strin return `exceeds ${value}${suffix} over ${min} min`; } -function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) { +function AlertRow({ name, value, min, systemName, integrationName, triggered, isAdvanced }: AlertRowProps) { const Icon = alertIconMap[name] ?? Server; const description = formatAlertDescription(name, value, min); + const accentColor = triggered ? "red" : "green"; + const borderColor = triggered ? "red" : "gray"; + const rowStyle = isAdvanced + ? { + borderRadius: "var(--mantine-radius-sm)", + border: `1px solid var(--mantine-color-${borderColor}-4)`, + backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)", + } + : { + borderRadius: "var(--mantine-radius-sm)", + borderLeft: `3px solid var(--mantine-color-${accentColor}-6)`, + backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)", + }; return ( - - - - - - - {integrationName ? `${systemName} · ${integrationName}` : systemName} - - - · - - - {name} - - - {showDescription && ( - - {description} - - )} - {!showDescription && {description}} - - - + + + + + + {integrationName ? `${systemName} · ${integrationName}` : systemName} + + + · + + + {name} + + + + {description} + + + ); } diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 9fc46f402..7751ae61b 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -17,7 +17,6 @@ export default function CoolifyWidget({ options, integrationIds, width, - height, displayMode, }: WidgetComponentProps<"coolify">) { const t = useI18n("widget.coolify"); @@ -38,7 +37,6 @@ export default function CoolifyWidget({ integrationIds={integrationIds} options={{ ...options, ...sectionVisibility }} width={width} - height={height} isAdvanced={isAdvanced} /> ); @@ -48,11 +46,10 @@ interface CoolifyContentProps { integrationIds: string[]; options: WidgetComponentProps<"coolify">["options"]; width: number; - height: number; isAdvanced: boolean; } -function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) { +function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) { const t = useI18n("common"); const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds, @@ -64,8 +61,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: ); const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null); - const isTiny = width < 256 || height < 144; - const hideFooter = height < 112; + const isTiny = width < 256; const [firstInstance] = successfulInstances; const widgetKey = createWidgetKey(integrationIds); @@ -100,7 +96,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: isTiny={!isAdvanced && isTiny} isAdvanced={isAdvanced} widgetKey={widgetKey} - hideFooter={!isAdvanced && hideFooter} />
@@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: isTiny={!isAdvanced && isTiny} isAdvanced={isAdvanced} widgetKey={widgetKey} - hideFooter={!isAdvanced && hideFooter} /> ))} diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx index d12de63e5..740a10945 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; +import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { useTimeAgo } from "@homarr/common"; @@ -22,12 +22,10 @@ interface InstanceCardProps { isTiny: boolean; isAdvanced: boolean; widgetKey: string; - hideFooter: boolean; } -export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey }: InstanceCardProps) { const t = useI18n("widget.coolify"); - const tCommon = useI18n("common"); const cardKey = `${widgetKey}-${instance.integrationId}`; const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${cardKey}`, @@ -53,32 +51,10 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, 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 ( - + Coolify @@ -102,19 +78,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, - {isTiny && totalResources > 0 ? ( - - - {healthyResources}/{totalResources} - - - ) : null} - {!isTiny && options.showServers && ( + {options.showServers && ( {onlineServers}/{instance.instanceInfo.servers.length} )} - {!isTiny && options.showApplications && ( + {options.showApplications && ( )} - {!isTiny && options.showServices && ( + {options.showServices && ( - {!hideFooter && } + ); } diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 7120275ce..9f1656e90 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -114,7 +114,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow )} - {!isTiny && logsUrl && ( + {logsUrl && ( )} - {!isTiny && ( - - {item.projectName ?? "-"} / {item.environmentName ?? "-"} - - )} - {!isTiny && resourceTimestamp && ( + + {item.projectName ?? "-"} / {item.environmentName ?? "-"} + + {resourceTimestamp && ( {resourceTimestamp} diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx index 49edbb03b..f7efba90e 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -22,17 +22,9 @@ interface SingleInstanceLayoutProps { isTiny: boolean; isAdvanced: boolean; widgetKey: string; - hideFooter: boolean; } -export function SingleInstanceLayout({ - instance, - options, - isTiny, - isAdvanced, - widgetKey, - hideFooter, -}: SingleInstanceLayoutProps) { +export function SingleInstanceLayout({ instance, options, isTiny, isAdvanced, widgetKey }: SingleInstanceLayoutProps) { const t = useI18n("widget.coolify"); const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${widgetKey}`, @@ -115,7 +107,7 @@ export function SingleInstanceLayout({ )} - {!hideFooter && } + ); diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 6ef443999..6ec7f7336 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -180,6 +180,7 @@ export type WidgetComponentProps = WidgetProps setOptions: ({ newOptions }: { newOptions: Partial>> }) => void; width: number; height: number; + displayScale?: number; widgetRuntimeRef?: WidgetRuntimeRef; widgetStateRef?: React.MutableRefObject | null>; removeItem?: () => void; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index f4d991b48..8ec849d2a 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -57,7 +57,7 @@ export default function DnsHoleControlsWidget({ integrationIds, isEditMode, width, - height, + displayMode, }: WidgetComponentProps) { const board = useRequiredBoard(); // DnsHole integrations with interaction permissions @@ -236,7 +236,7 @@ export default function DnsHoleControlsWidget({ t={t} hasIconColor={board.iconColor !== null} rootWidth={width} - rootHeight={height} + isAdvanced={displayMode === "advanced"} actionsPending={actionsPending} /> ))} @@ -275,7 +275,7 @@ interface ControlsCardProps { t: ReturnType>; hasIconColor: boolean; rootWidth: number; - rootHeight: number; + isAdvanced: boolean; actionsPending: boolean; } @@ -288,7 +288,7 @@ const ControlsCard: React.FC = ({ t, hasIconColor, rootWidth, - rootHeight, + isAdvanced, actionsPending, }) => { const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 }); @@ -299,7 +299,7 @@ const ControlsCard: React.FC = ({ const board = useRequiredBoard(); const iconUrl = integrationDefs[data.integration.kind].iconUrl; - const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md"; + const layout = !isAdvanced && rootWidth < 256 ? "sm" : "md"; return ( { ); }; -const ContainerStateDot = ({ state }: { state: ContainerState }) => { - const t = useI18n("docker.field.state.option"); - const label = t(state); - - return ( - - - - - ); -}; - const createColumns = ( t: ReturnType>, tCommon: ReturnType>, handlers: ContainerActionHandlers, sortingEnabled: boolean, - inlineState: boolean, ): DataTableColumn[] => [ { accessor: "name", @@ -147,7 +124,6 @@ const createColumns = ( sortable: sortingEnabled, render: (container) => ( - {inlineState && } getDockerColumnVisibility(options.columns, width, isAdvanced), [isAdvanced, options.columns, width], ); - const inlineState = - !isAdvanced && width < 340 && options.columns.includes("name") && options.columns.includes("state"); const columns = useMemo(() => { const sortingEnabled = (isAdvanced || options.enableRowSorting) && !isEditMode; - return createColumns(t, tCommon, actionHandlers, sortingEnabled, inlineState).filter( + return createColumns(t, tCommon, actionHandlers, sortingEnabled).filter( ({ accessor }) => columnVisibility[String(accessor) as keyof typeof columnVisibility], ); - }, [actionHandlers, columnVisibility, inlineState, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]); + }, [actionHandlers, columnVisibility, isAdvanced, isEditMode, options.enableRowSorting, t, tCommon]); const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, columnAccessors, @@ -435,7 +409,7 @@ export default function DockerWidget({ { - test("keeps only essential columns in a narrow compact widget", () => { + test("keeps every configured column in a narrow compact widget", () => { expect(getDockerColumnVisibility(columns, 240, false)).toEqual({ name: true, - state: false, - host: false, - cpuUsage: false, - memoryUsage: false, + state: true, + host: true, + cpuUsage: true, + memoryUsage: true, actions: true, }); }); - test("reveals compact metrics as width becomes available", () => { - expect(getDockerColumnVisibility(columns, 440, false)).toMatchObject({ + test("honors the configured compact column set at every width", () => { + expect(getDockerColumnVisibility(["name", "memoryUsage"], 200, false)).toEqual({ + name: true, + state: false, host: false, - cpuUsage: true, - memoryUsage: false, + cpuUsage: false, + memoryUsage: true, + actions: false, + }); + expect(getDockerColumnVisibility(["name", "memoryUsage"], 800, false)).toEqual({ + name: true, + state: false, + host: false, + cpuUsage: false, + memoryUsage: true, + actions: false, }); - }); - - test("keeps a configured state column when there is no name column for the inline dot", () => { - expect(getDockerColumnVisibility(["state"], 240, false).state).toBe(true); }); test("uses every expert column in advanced mode", () => { @@ -41,9 +48,9 @@ describe("getDockerColumnVisibility", () => { }); describe("getDockerFooterVisibility", () => { - test("reveals totals only when they fit", () => { - expect(getDockerFooterVisibility(300, false)).toEqual({ footer: true, cpu: false, memory: false }); - expect(getDockerFooterVisibility(400, false)).toEqual({ footer: true, cpu: true, memory: false }); + test("shows the full compact footer above its threshold", () => { + expect(getDockerFooterVisibility(256, false)).toEqual({ footer: false, cpu: false, memory: false }); + expect(getDockerFooterVisibility(257, false)).toEqual({ footer: true, cpu: true, memory: true }); }); test("keeps every total in advanced mode", () => { diff --git a/packages/widgets/src/docker/layout.ts b/packages/widgets/src/docker/layout.ts index 465ac51fc..aedc59c71 100644 --- a/packages/widgets/src/docker/layout.ts +++ b/packages/widgets/src/docker/layout.ts @@ -2,7 +2,7 @@ export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage export const getDockerColumnVisibility = ( configuredColumns: readonly DockerColumn[], - width: number, + _width: number, isAdvanced: boolean, ): Record => { const configured = new Set(configuredColumns); @@ -10,16 +10,16 @@ export const getDockerColumnVisibility = ( return { name: isVisible("name"), - state: isVisible("state") && (isAdvanced || width >= 340 || !configured.has("name")), - host: isVisible("host") && (isAdvanced || width >= 640), - cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 420), - memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 520), + state: isVisible("state"), + host: isVisible("host"), + cpuUsage: isVisible("cpuUsage"), + memoryUsage: isVisible("memoryUsage"), actions: isVisible("actions"), }; }; export const getDockerFooterVisibility = (width: number, isAdvanced: boolean) => ({ footer: isAdvanced || width > 256, - cpu: isAdvanced || width >= 360, - memory: isAdvanced || width >= 480, + cpu: isAdvanced || width > 256, + memory: isAdvanced || width > 256, }); diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index e1195f7f3..db872e437 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -159,10 +159,8 @@ const speedColumnConfig = { up: { Icon: IconUpload, color: "green" as const }, } as const; -export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig { +export function getSizeConfig(width: number, _height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig { if (isAdvanced) return DEFAULT_SIZE_CONFIG; - if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG; - if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG; for (const { maxWidth, config } of SIZE_BREAKPOINTS) { if (width < maxWidth) return config; } @@ -796,25 +794,23 @@ export default function DownloadClientsWidget({ /> - {(isAdvanced || height >= 96) && ( - - )} + {contextMenu && } diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx index b333f4e6c..f9f265c9e 100644 --- a/packages/widgets/src/firewall/component.tsx +++ b/packages/widgets/src/firewall/component.tsx @@ -68,7 +68,6 @@ export default function FirewallWidget({ const isAdvanced = displayMode === "advanced"; const isTiny = !isAdvanced && (width < 256 || height < 180); const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100; - const showInterfaces = isAdvanced || (!isTiny && height >= 120); const t = useI18n("widget.firewall"); const tCommon = useI18n("common"); @@ -179,7 +178,6 @@ export default function FirewallWidget({ hasError={hasError} isAdvanced={isAdvanced} ringSize={ringSize} - showInterfaces={showInterfaces} accordionValue={accordionValue} setAccordionValue={setAccordionValue} errorLabel={t("error.internalServerError")} @@ -210,7 +208,6 @@ interface FirewallPanelProps { hasError: boolean; isAdvanced: boolean; ringSize: number; - showInterfaces: boolean; accordionValue: string | null; setAccordionValue: (value: string | null) => void; errorLabel: string; @@ -234,7 +231,6 @@ const FirewallPanel = ({ hasError, isAdvanced, ringSize, - showInterfaces, accordionValue, setAccordionValue, errorLabel, @@ -283,21 +279,19 @@ const FirewallPanel = ({ )} - {showInterfaces && ( - - )} + ); diff --git a/packages/widgets/src/immich/server-stats/component.module.css b/packages/widgets/src/immich/server-stats/component.module.css index e7ece8d6b..85fa52d86 100644 --- a/packages/widgets/src/immich/server-stats/component.module.css +++ b/packages/widgets/src/immich/server-stats/component.module.css @@ -14,9 +14,21 @@ background-color: transparent; } +.statItemCompact { + padding: 12px; + border-radius: var(--mantine-radius-md); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); + transition: background-color 0.2s ease; + + &:hover { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + } +} + @media (forced-colors: active) { .statItem, - .statItemDense { + .statItemDense, + .statItemCompact { border-color: CanvasText; } } diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx index b4e684fed..d0f5ff65f 100644 --- a/packages/widgets/src/immich/server-stats/component.tsx +++ b/packages/widgets/src/immich/server-stats/component.tsx @@ -60,54 +60,57 @@ export default function ImmichServerStatsWidget({ { key: "users", enabled: statVisibility.showUsers, - icon: , + icon: , label: t("users"), value: stats.userCount.toLocaleString(locale), }, { key: "photos", enabled: statVisibility.showPhotos, - icon: , + icon: , label: t("photos"), value: stats.photoCount.toLocaleString(locale), }, { key: "videos", enabled: statVisibility.showVideos, - icon: , + icon: , label: t("videos"), value: stats.videoCount.toLocaleString(locale), }, { key: "storage", enabled: statVisibility.showStorage, - icon: , + icon: , label: t("storage"), value: formatBytes(stats.totalLibraryUsageInBytes), }, ] .filter((item) => item.enabled) .map(({ key, icon, label, value }) => ( - + )); - const statsContent = - isAdvanced || statsLayout.dense ? ( - - {statItems} - - ) : ( - {statItems} - ); - if (!isAdvanced) { return ( - - {statsContent} + + {statItems} ); } + const statsContent = ( + + {statItems} + + ); const sortedAlbums = albums; const maxAssets = sortedAlbums[0]?.assetCount ?? 1; return ( @@ -150,9 +153,10 @@ interface StatItemProps { label: string; value: string | number; dense: boolean; + compact: boolean; } -function StatItem({ icon, label, value, dense }: StatItemProps) { +function StatItem({ icon, label, value, dense, compact }: StatItemProps) { if (dense) { return ( @@ -167,7 +171,7 @@ function StatItem({ icon, label, value, dense }: StatItemProps) { } return ( - + {icon} @@ -181,16 +185,13 @@ function StatItem({ icon, label, value, dense }: StatItemProps) { ); } -export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => { +export const getImmichStatsLayout = (width: number, _height: number, itemCount: number, isAdvanced = false) => { const count = Math.max(1, itemCount); if (isAdvanced) { return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false }; } - const dense = width < 320 || height < 160; - if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true }; - if (height < 220) return { columns: Math.min(count, 2), dense: true }; - return { columns: Math.min(count, width >= 280 ? 2 : 1), dense }; + return { columns: 1, dense: false }; }; export const getImmichStatVisibility = ( diff --git a/packages/widgets/src/immich/server-stats/layout.spec.ts b/packages/widgets/src/immich/server-stats/layout.spec.ts index d8e7baf75..78546b4e8 100644 --- a/packages/widgets/src/immich/server-stats/layout.spec.ts +++ b/packages/widgets/src/immich/server-stats/layout.spec.ts @@ -3,14 +3,11 @@ import { describe, expect, test } from "vitest"; import { getImmichStatsLayout, getImmichStatVisibility } from "./component"; describe("Immich server statistics compact layout", () => { - test("keeps four enabled stats legible in a short widget", () => { - expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true }); - expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true }); - }); - - test("uses the roomier summary layout when space is available", () => { - expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false }); - expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true }); + test("keeps the v1 one-column non-dense layout at every compact size", () => { + expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 1, dense: false }); + expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 1, dense: false }); + expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 1, dense: false }); + expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 1, dense: false }); }); test("advanced mode shows all four headline stats", () => { diff --git a/packages/widgets/src/indexer-manager/component.module.css b/packages/widgets/src/indexer-manager/component.module.css index 81c9e2d4c..cc088e8d3 100644 --- a/packages/widgets/src/indexer-manager/component.module.css +++ b/packages/widgets/src/indexer-manager/component.module.css @@ -1,4 +1,8 @@ .card { + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7)); +} + +.advancedCard { background-color: transparent; border: 1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38)); } diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index b1918a968..209c639b7 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -5,6 +5,7 @@ import { ActionIcon, Anchor, Badge, + Button, Card, Center, Flex, @@ -23,6 +24,7 @@ import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; +import { iconSizes } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import { useWidgetRuntimeActions } from "../runtime-hooks"; @@ -67,8 +69,7 @@ export default function IndexerManagerWidget({ const board = useRequiredBoard(); const isAdvanced = displayMode === "advanced"; const hasSmallWidth = !isAdvanced && width < 256; - const isDense = !isAdvanced && (width < 280 || height < 180); - const showHealthCounts = isAdvanced || (width >= 200 && height >= 100); + const hasSmallHeight = !isAdvanced && height < 256; const allIndexers = indexersData.flatMap((entry) => entry.indexers); const hasSourceError = indexersData.some((entry) => Boolean(entry.error)); const unavailableCount = allIndexers.filter( @@ -88,24 +89,32 @@ export default function IndexerManagerWidget({ className={`indexer-manager-container ${classes.root}`} h="100%" direction="column" - gap={isDense ? 4 : "xs"} - p={isDense ? "xs" : "sm"} + gap={isAdvanced ? "xs" : "sm"} + p="sm" + align={isAdvanced ? undefined : "center"} > - + {testAllError && {tCommon("error")}} - {!hasSmallWidth && ( - - {t("title")} - - )} - {showHealthCounts && ( + + {t("title")} + + {isAdvanced && ( {allIndexers.length - unavailableCount} @@ -117,29 +126,35 @@ export default function IndexerManagerWidget({ )} )} - - { - testAllIndexers(); - }} - aria-label={t("testAll")} - > - - - + {(isAdvanced || hasSmallHeight) && ( + + { + testAllIndexers(); + }} + aria-label={t("testAll")} + > + + + + )} @@ -153,7 +168,7 @@ export default function IndexerManagerWidget({ {indexersData.map(({ integrationId, integrationName, indexers, error }) => ( - {(isAdvanced || indexersData.length > 1 || error) && ( + {(isAdvanced || error) && ( {integrationName} @@ -171,14 +186,18 @@ export default function IndexerManagerWidget({ const presentation = statusPresentation[displayStatus]; return ( {indexer.name} @@ -201,14 +220,14 @@ export default function IndexerManagerWidget({ ) : displayStatus === "healthy" ? ( ) : ( )} @@ -219,6 +238,25 @@ export default function IndexerManagerWidget({ )} + {!isAdvanced && !hasSmallHeight && ( + + )} ); } diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 568cb8030..8abd39cbd 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -40,7 +40,6 @@ export default function MediaReleasesWidget({ options, integrationIds, width, - height, displayMode, }: WidgetComponentProps<"mediaReleases">) { const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds }); @@ -63,7 +62,7 @@ export default function MediaReleasesWidget({ {releases.map((item, index) => ( {!isAdvanced && index !== 0 && options.layout === "poster" && } - + ))} @@ -82,30 +81,27 @@ interface ItemProps { item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; isAdvanced: boolean; - width: number; - height: number; } -const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => { +const formatReleaseDate = (value: unknown, locale: string) => { const date = toValidDate(value); if (!date) return "—"; return Intl.DateTimeFormat(locale, { - month: compact ? "short" : "2-digit", + month: "2-digit", year: "numeric", day: "2-digit", - ...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }), + hour: "2-digit", + minute: "2-digit", + hour12: false, }).format(date); }; -const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { +const Item = ({ item, options, isAdvanced }: ItemProps) => { const locale = useCurrentIntlLocale(); const t = useI18n("widget.mediaReleases"); const length = formatLength(item.length, item.type, t); - const isCompact = !isAdvanced && (width < 340 || height < 180); - const isTiny = !isAdvanced && (width < 220 || height < 105); - const showPoster = (isAdvanced || options.layout === "poster") && !isTiny; - const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360); - const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource)); + const showPoster = isAdvanced || options.layout === "poster"; + const showSide = isAdvanced || options.showType || options.showSource; const href = getSafeApplicationUrl(item.href); return ( @@ -129,7 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { target={href ? "_blank" : undefined} rel={href ? SAFE_NEW_TAB_REL : undefined} pos="relative" - p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6} + p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4} h="100%" title={item.title} style={ @@ -158,57 +154,61 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { }} /> )} - + {showPoster && ( )} - + - + {item.title} - {item.subtitle !== undefined && !isTiny && ( - + {item.subtitle !== undefined && ( + {item.subtitle} )} - - - {length !== undefined && !isTiny && ( + + + {length !== undefined && ( <> )} - {item.producer !== undefined && showExtendedMetadata && ( + {item.producer !== undefined && ( <> )} - {item.rating !== undefined && showExtendedMetadata && ( + {item.rating !== undefined && ( <> )} - {item.price !== undefined && showExtendedMetadata && ( + {item.price !== undefined && ( <> )} - {item.tags.length > 0 && (isAdvanced || !isCompact) && ( + {item.tags.length > 0 && ( ) { const interactIntegrationIds = new Set( useIntegrationsWithInteractAccess() @@ -69,8 +68,7 @@ export default function MediaServerWidget({ ["options"]; } -const MediaRequestCard = ({ - request, - isTiny, - isDense, - showIntegrationSource, - canInteract, - options, -}: MediaRequestCardProps) => { +const MediaRequestCard = ({ request, isTiny, showIntegrationSource, canInteract, options }: MediaRequestCardProps) => { const board = useRequiredBoard(); const t = useI18n("widget.mediaRequests-requestList"); const requestHref = getSafeApplicationUrl(request.href); @@ -124,11 +114,21 @@ const MediaRequestCard = ({ return ( + - + {!isTiny && ( )} - - - {request.name || "unknown"} - - {request.status === "pending" ? ( - - ) : ( - - )} - - - + + {toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")} @@ -206,27 +172,52 @@ const MediaRequestCard = ({ )} - {!isTiny && ( - - - - {(request.requestedBy?.displayName ?? "") || "unknown"} - - + + + + {(request.requestedBy?.displayName ?? "") || "unknown"} + + + + + + {request.name || "unknown"} + + {request.status === "pending" ? ( + + ) : ( + )} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 07a67bd34..92aaac4c6 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -100,9 +100,7 @@ export default function MediaServerWidget({ }, ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; - const isTiny = width < 256 || height < 180; - const showSectionTitles = height >= 180; - const visibleStats = data.slice(0, height < 120 ? 4 : 8); + const isTiny = width < 256; const showIntegrationLegend = requestStats.integrations.length > 1 && height >= 240; return ( @@ -121,13 +119,11 @@ export default function MediaServerWidget({ style={{ pointerEvents: isEditMode ? "none" : undefined, overflow: "hidden" }} > - {showSectionTitles && ( - - {t("titles.stats.main")} - - )} + + {t("titles.stats.main")} + - {visibleStats.map((stat) => ( + {data.map((stat) => ( - {showSectionTitles && ( - - {t("titles.users.main")} ({t("titles.users.requests")}) - - )} + + {t("titles.users.main")} ({t("titles.users.requests")}) + {requestStats.users.map((user) => { diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx index fe43d916d..55557b8b2 100644 --- a/packages/widgets/src/media-server/component.spec.tsx +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -106,10 +106,10 @@ describe("SessionDetailsPopover", () => { }); describe("getMediaServerColumnVisibility", () => { - test("keeps a stable responsive column model from the zero-sized first render", () => { - expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false }); - expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false }); - expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false }); + test("keeps every compact column visible at every width", () => { + expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: true, status: true }); + expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: true }); + expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true }); }); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index f4b67a648..6379560d8 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -48,11 +48,9 @@ type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transc type SortColumn = "user" | "currentlyPlaying" | "status"; type SortState = { column: SortColumn; descending: boolean } | null; -export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ - user: isAdvanced || width >= 300, - // The user (26%) and status (22%) columns together need a floor here, otherwise the - // currentlyPlaying column - the primary content - gets squeezed to almost nothing. - status: isAdvanced || width >= 540, +export const getMediaServerColumnVisibility = (_width: number, _isAdvanced: boolean) => ({ + user: true, + status: true, }); function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { @@ -171,7 +169,6 @@ export default function MediaServerWidget({ options, integrationIds, width, - height, isEditMode, displayMode, }: WidgetComponentProps<"mediaServer">) { @@ -411,61 +408,73 @@ export default function MediaServerWidget({
- {(isAdvanced || height >= 144) && ( - - - - - {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { - count: flatSessions.length, - })} - - {totalBitrateLabel && (isAdvanced || width >= 300) && ( - <> + + + + + {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { + count: flatSessions.length, + })} + + {totalBitrateLabel && ( + <> + {isAdvanced && ( • - - {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} - - - )} - - - {uniqueIntegrations.map((integration) => ( - - - {(isAdvanced || width >= 480) && ( - - {integration.integrationName} - - )} - - ))} - + )} + + {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} + + + )} - )} + + {uniqueIntegrations.map((integration) => ( + + + + {integration.integrationName} + + + ))} + + ); } diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index d8ce9d293..38b9d423e 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -15,6 +15,7 @@ export default function MinecraftServerStatusWidget({ options, width, height, + displayMode, }: WidgetComponentProps<"minecraftServerStatus">) { const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options); const t = useI18n("widget.minecraftServerStatus"); @@ -34,10 +35,11 @@ export default function MinecraftServerStatusWidget({ const { data } = result; const title = options.title.trim().length > 0 ? options.title : options.domain; - const isDense = width < 220 || height < 120; - const showServerIcon = !isDense && height >= 144; - const showMetadata = width >= 260 && height >= 180; - const showCapacity = width >= 180 && height >= 140; + const isAdvanced = displayMode === "advanced"; + const isDense = isAdvanced && (width < 220 || height < 120); + const showServerIcon = !isAdvanced || (!isDense && height >= 144); + const showMetadata = isAdvanced && width >= 260 && height >= 180; + const showCapacity = isAdvanced && width >= 180 && height >= 140; const iconSize = Math.max(40, Math.min(80, width * 0.45, height * 0.45)); const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0; diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index ea5d1b8e3..5cb24cd9b 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -281,6 +281,7 @@ const WidgetEditPreview = ({ integrationIds={state.integrationIds} width={sourceWidth} height={sourceHeight} + displayScale={previewScale} isEditMode={isPendingCustomWidget} displayMode="compact" boardId={boardId} diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index 2159b4c71..07e53e013 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -1,7 +1,6 @@ "use client"; -import { useMemo } from "react"; -import { Badge, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; @@ -26,13 +25,13 @@ export default function NetworkControllerNetworkStatusWidget({ const summaryQuery = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const results = getUsableWidgetQueryData(summaryQuery) ?? []; - const summaries = useMemo( - () => - results.filter( - (result): result is typeof result & { summary: NonNullable } => result.summary !== null, - ), - [results], + const isAdvanced = displayMode === "advanced"; + let results = summaryQuery.data ?? []; + if (isAdvanced) { + results = getUsableWidgetQueryData(summaryQuery) ?? []; + } + const summaries = results.filter( + (result): result is typeof result & { summary: NonNullable } => result.summary !== null, ); const tWidgetCommon = useI18n("widget.common"); const tCommon = useI18n("common"); @@ -43,13 +42,31 @@ export default function NetworkControllerNetworkStatusWidget({ const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0); const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0); const layout = getNetworkControllerStatusLayout({ width, height, displayMode, content: options.content }); - const isAdvanced = displayMode === "advanced"; + const indicatorResults = + results.length > 0 || !summaryQuery.error + ? results + : integrationIds.map((integrationId) => ({ integrationId, error: "query" })); const queryIndicators = ( - + ); + if (!isAdvanced) { + return ( + + + {queryIndicators} + + {options.content === "wifi" ? ( + + ) : ( + + )} + + ); + } + if (summaryQuery.isPending || summaries.length === 0) { return ( @@ -63,92 +80,50 @@ export default function NetworkControllerNetworkStatusWidget({ ); } - if (isAdvanced) { - return ( - - {queryIndicators} - 1 ? layout.sourceColumns : 1} spacing="sm"> - {summaries.map(({ integration, summary, updatedAt }) => ( - - - - - - {integration.name} - - {updatedAt && ( - - {tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })} - - )} - - - {integration.kind} - - - - - - - - - - - - - ))} - - - ); - } - return ( - + {queryIndicators} - - {layout.showWifi && ( - - + 1 ? layout.sourceColumns : 1} spacing="sm"> + {summaries.map(({ integration, summary, updatedAt }) => ( + + + + + + {integration.name} + + {updatedAt && ( + + {tWidgetCommon("updatedAt", { date: new Date(updatedAt).toLocaleString(locale) })} + + )} + + + {integration.kind} + + + + + + + + + + + - )} - {layout.showWired && ( - - - - )} + ))} - + ); } diff --git a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx index e2d0e279b..6b5eac61c 100644 --- a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx @@ -3,12 +3,27 @@ import { Stack, Text } from "@mantine/core"; export const StatRow = ({ label, value, + advanced = false, compact = false, }: { label: string; value: string | number; + advanced?: boolean; compact?: boolean; }) => { + if (!advanced) { + return ( + + + {value} + + + {label} + + + ); + } + return ( diff --git a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx index 67b6ac28b..036af0ccc 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx @@ -2,22 +2,41 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconWifi } from "@tabler/icons-react"; import { useI18n } from "@homarr/translation/client"; -import { iconSizes } from "@homarr/ui"; +import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { StatRow } from "./stat-row"; export const WifiVariant = ({ countGuests, countUsers, + advanced = false, compact = false, horizontal = false, }: { countUsers: number; countGuests: number; + advanced?: boolean; compact?: boolean; horizontal?: boolean; }) => { const t = useI18n("widget.networkControllerStatus.card"); + if (!advanced) { + return ( + <> + + + + {t("variants.wifi.name")} + + + + + + + + ); + } + return ( @@ -29,8 +48,8 @@ export const WifiVariant = ({ - - + + ); diff --git a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx index 4000adb10..2e940fe2b 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx @@ -2,22 +2,41 @@ import { Center, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconNetwork } from "@tabler/icons-react"; import { useI18n } from "@homarr/translation/client"; -import { iconSizes } from "@homarr/ui"; +import { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { StatRow } from "./stat-row"; export const WiredVariant = ({ countGuests, countUsers, + advanced = false, compact = false, horizontal = false, }: { countUsers: number; countGuests: number; + advanced?: boolean; compact?: boolean; horizontal?: boolean; }) => { const t = useI18n("widget.networkControllerStatus.card"); + if (!advanced) { + return ( + <> + + + + {t("variants.wired.name")} + + + + + + + + ); + } + return ( @@ -29,8 +48,8 @@ export const WiredVariant = ({
- - + + ); diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index 09870e667..ad3c85d36 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { CSSProperties } from "react"; -import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Box, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core"; import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; @@ -27,13 +27,16 @@ dayjs.extend(duration); export default function NetworkControllerSummaryWidget({ integrationIds, width, - height, displayMode, }: WidgetComponentProps<"networkControllerSummary">) { const summaryQuery = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const results = getUsableWidgetQueryData(summaryQuery) ?? []; + const isAdvanced = displayMode === "advanced"; + let results = summaryQuery.data ?? []; + if (isAdvanced) { + results = getUsableWidgetQueryData(summaryQuery) ?? []; + } const summaries = results.filter( (result): result is typeof result & { summary: NonNullable; updatedAt: Date } => result.summary !== null && result.updatedAt !== undefined, @@ -51,16 +54,86 @@ export default function NetworkControllerSummaryWidget({ unknown: tDns("status.unknown"), }; - const isDense = height < 160; - const showDetails = height >= 180 && width >= 240; - const columns = width >= 720 ? 2 : 1; - const isAdvanced = displayMode === "advanced"; + const indicatorResults = + results.length > 0 || !summaryQuery.error + ? results + : integrationIds.map((integrationId) => ({ integrationId, error: "query" })); const queryIndicators = ( - + ); + if (!isAdvanced) { + const summary = summaries[0]?.summary; + + return ( + + + {queryIndicators} + +
+ + + } + > + {t("card.wan")} + + + } + > + + WWW + + {summary?.www.latency ?? 0}ms + + + + + } + > + {t("card.wifi")} + + + } + > + + {t("card.vpn.label")} + + {t("card.vpn.countConnected", { count: summary?.vpn.users ?? 0 })} + + + + +
+
+ ); + } + if (isPending || summaries.length === 0) { return ( @@ -74,190 +147,82 @@ export default function NetworkControllerSummaryWidget({ ); } - if (isAdvanced) { - const controllerColumns = width >= 960 ? 2 : 1; - const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1; - - return ( - - {queryIndicators} - - {summaries.map(({ integration, summary, updatedAt }) => ( - - - - - - {integration.name} - - - {integration.kind} - - - - - {t("advanced.updated")} - - - {new Date(updatedAt).toLocaleString(locale)} - - - - - {getNetworkControllerMatrix(summary).map((section) => ( - - - - - - - {getMatrixSectionLabel(section.key, t)} - - - - {statusLabels[getBinaryStatusKey(section.status)]} - - - {section.metrics.length > 0 && ( - - {section.metrics.map((metric) => ( - - - {getMatrixMetricLabel(metric.key, t)} - - - {formatMatrixMetric(metric.key, metric.value)} - - - ))} - - )} - - - ))} - - - - ))} - - - ); - } + const controllerColumns = width >= 960 ? 2 : 1; + const matrixColumns = width >= 720 ? 3 : width >= 440 ? 2 : 1; return ( - + {queryIndicators} - - {summaries.map(({ integration, summary }) => ( - 1} - bg="transparent" - style={{ borderColor: neutralSurfaceBorder }} - > - - {summaries.length > 1 && ( - + + {summaries.map(({ integration, summary, updatedAt }) => ( + + + + {integration.name} - + {integration.kind} - - - )} - - - } - > - {t("card.wan")} - - - } - > - - {t("card.web")} - - {summary.www.latency}ms{showDetails ? ` · ${summary.www.ping}ms` : ""} - - - - } - > - {t("card.wifi")} - {showDetails ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""} - - {showDetails && ( - - } + + + + {t("advanced.updated")} + + + {new Date(updatedAt).toLocaleString(locale)} + + + + + {getNetworkControllerMatrix(summary).map((section) => ( + - {t("card.lan")} · {summary.lan.users + summary.lan.guests} - - )} - - } - > - - {t("card.vpn.label")} - - {t("card.vpn.countConnected", { count: summary.vpn.users })} - - - - + + + + + + {getMatrixSectionLabel(section.key, t)} + + + + {statusLabels[getBinaryStatusKey(section.status)]} + + + {section.metrics.length > 0 && ( + + {section.metrics.map((metric) => ( + + + {getMatrixMetricLabel(metric.key, t)} + + + {formatMatrixMetric(metric.key, metric.value)} + + + ))} + + )} + + + ))} + ))} - + ); } @@ -280,26 +245,23 @@ const getMatrixSectionLabel = ( return t(`card.${key}` as never); }; -const neutralSurfaceBorder = - "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; +const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; const StatusIcon = ({ status, label, - size, style, }: { status?: "enabled" | "disabled"; label: string; - size?: number | string; style?: CSSProperties; }) => { const mantineTheme = useMantineTheme(); if (status === "enabled") { - return ; + return ; } if (status === "disabled") { - return ; + return ; } - return ; + return ; }; diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx index b020ed9bf..375ea8ad2 100644 --- a/packages/widgets/src/notifications/component.tsx +++ b/packages/widgets/src/notifications/component.tsx @@ -52,21 +52,22 @@ export default function NotificationsWidget({ const failedIntegrations = notificationIntegrations.filter( (integration): integration is typeof integration & { error: string } => Boolean(integration.error), ); + const isAdvanced = displayMode === "advanced"; const isDense = width < 280 || height < 180; const isRoomy = width >= 360 && height >= 220; const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4; const notificationDisplay = getNotificationDisplay({ displayMode, hideLogos: options.hideLogos, - isRoomy, - bodyLineClamp, + isRoomy: isAdvanced && isRoomy, + bodyLineClamp: isAdvanced ? bodyLineClamp : 4, }); - const columns = width >= 720 ? 2 : 1; - const spacing = isRoomy ? "sm" : "xs"; + const columns = isAdvanced && width >= 720 ? 2 : 1; + const spacing = !isAdvanced || isRoomy ? "sm" : "xs"; return ( - - + + {failedIntegrations.length > 0 && ( {failedIntegrations.map((integration) => ( @@ -93,34 +94,39 @@ export default function NotificationsWidget({ href={href} target={href ? "_blank" : undefined} rel={href ? SAFE_NEW_TAB_REL : undefined} - className={columns > 1 ? classes.card : classes.row} + className={isAdvanced ? (columns > 1 ? classes.card : classes.row) : undefined} radius={board.itemRadius} w="100%" - p={isRoomy ? "sm" : isDense ? 6 : "xs"} + p={!isAdvanced || isRoomy ? "sm" : isDense ? 6 : "xs"} style={{ color: "inherit", textDecoration: "none", }} > - + {notificationDisplay.showLogos && notification.source?.iconUrl && ( )} - + {notification.title && ( - + {notification.title} )} @@ -134,7 +140,7 @@ export default function NotificationsWidget({ ? (notification.source?.name ?? notification.integrationName) : undefined } - dense={isDense} + dense={isAdvanced && isDense} /> @@ -159,11 +165,7 @@ const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; den return ( - + {timeAgo} diff --git a/packages/widgets/src/paperless-ngx/component.module.css b/packages/widgets/src/paperless-ngx/component.module.css index 0d98c79eb..660f9cbfe 100644 --- a/packages/widgets/src/paperless-ngx/component.module.css +++ b/packages/widgets/src/paperless-ngx/component.module.css @@ -16,8 +16,7 @@ gap: clamp(8px, 3cqi, 18px); flex-shrink: 0; padding-bottom: clamp(4px, 2cqi, 10px); - border-bottom: 1px solid - rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); + border-bottom: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); } .heroExpanded { @@ -98,9 +97,7 @@ gap: clamp(2px, 1.5cqi, 5px); padding: clamp(4px, 2.5cqi, 10px); border-radius: var(--mantine-radius-md); - border: 1px solid - rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45)); - background-color: transparent; + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); overflow: hidden; } @@ -110,7 +107,11 @@ } .statLabel { - font-size: clamp(8px, 2.5cqi, 12px); + font-size: clamp( + calc(8px * var(--board-canvas-ui-scale, 1)), + calc(2.5cqi * var(--board-canvas-ui-scale, 1)), + calc(12px * var(--board-canvas-ui-scale, 1)) + ); color: var(--mantine-color-dimmed); line-height: 1.2; text-align: center; @@ -121,9 +122,13 @@ } .statValue { - font-size: clamp(14px, 5.5cqi, 24px); + font-size: clamp( + calc(14px * var(--board-canvas-ui-scale, 1)), + calc(5.5cqi * var(--board-canvas-ui-scale, 1)), + calc(24px * var(--board-canvas-ui-scale, 1)) + ); font-weight: 700; - color: var(--mantine-color-text); + color: var(--mantine-primary-color-filled); line-height: 1.1; text-align: center; } diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index 25b40da61..71eb20255 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -75,7 +75,8 @@ export default function PaperlessNgxWidget({ integrationIds, options, width, - height, + displayScale = 1, + displayMode = "compact", }: WidgetComponentProps<"paperlessNgx">) { const t = useI18n("widget.paperlessNgx"); const tCommon = useI18n("common"); @@ -116,19 +117,22 @@ export default function PaperlessNgxWidget({ .map(([, statKey]) => statKey) .filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey))); - const gridCols = Math.max(1, Math.min(visibleStatKeys.length, getGridCols(width, height))); - const ringSize = getRingSize(Math.min(width, height * 2)); - const iconSize = getIconSize(width); + let responsiveWidth = width; + if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + } + const gridCols = getGridCols(responsiveWidth); + const ringSize = getRingSize(responsiveWidth); + const iconSize = getIconSize(responsiveWidth); const ringLabelSize = getRingLabelSize(ringSize); const hasContent = showHero || visibleStatKeys.length > 0; const heroLayoutClass = heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; - const showInboxRing = options.showInboxRing && height >= 120; - const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing]; + const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing]; const visibleHeroParts = Object.entries(heroPartVisibility).filter( - ([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing), + ([, optionKey]) => options[optionKey as keyof typeof options], ); const heroPartRenderers = { @@ -205,9 +209,9 @@ function computeInboxProgress(total: number, inbox: number): number { return Math.round((inbox / total) * 100); } -function getGridCols(width: number, height: number): number { +function getGridCols(width: number): number { const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); - return Math.min(match?.cols ?? 1, height < 140 ? 2 : 5); + return match?.cols ?? 1; } function getRingSize(width: number): number { diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 6592433e8..f109fe9f1 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -65,7 +65,7 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }: defaultValue: {}, }); const isAdvanced = displayMode === "advanced"; - const columns = Math.max(1, Math.floor(width / 420)); + const columns = isAdvanced ? Math.max(1, Math.floor(width / 420)) : 1; const relativeDateOptions = useMemo( () => ({ diff --git a/packages/widgets/src/rssFeed/component.module.scss b/packages/widgets/src/rssFeed/component.module.scss index 60fe1a072..e8274ac96 100644 --- a/packages/widgets/src/rssFeed/component.module.scss +++ b/packages/widgets/src/rssFeed/component.module.scss @@ -24,6 +24,18 @@ flex-shrink: 0; } +.backgroundImage { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(5px); + transform: scaleX(-1.05) scaleZ(-1.05); + opacity: 0.25; +} + @media (prefers-reduced-motion: reduce) { .entry { transition: none; diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 2d2cb92bf..158fbd06e 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core"; +import { Alert, Card, Flex, Group, Image, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconAlertTriangle, IconClock, IconServerOff } from "@tabler/icons-react"; import dayjs from "dayjs"; @@ -104,9 +104,10 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC descriptionLines, }); const spacing = isRoomy ? "sm" : "xs"; + const isNarrow = width < 128 * 3; return ( - + {warning && ( {warning} )} - - {feed.entries.map((feedEntry) => { - const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); - return ( - - - {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( - - )} - - - - {feedEntry.title} - - {entryDisplay.showDescription && feedEntry.description && ( - - {feedDescriptionToText(feedEntry.description)} - + {isAdvanced ? ( + + {feed.entries.map((feedEntry) => { + const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); + return ( + + + {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( + )} - - - - - ); - })} - + + + {feedEntry.title} + + {entryDisplay.showDescription && feedEntry.description && ( + + {feedDescriptionToText(feedEntry.description)} + + )} + + + + + + ); + })} + + ) : ( + + {feed.entries.map((feedEntry) => { + const href = getSafeApplicationUrl(feedEntry.link, { baseUrl: feedEntry.feedUrl }); + return ( + + {feedEntry.enclosure !== undefined && ( + + )} + + {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( + {feedEntry.title} + )} + + + {feedEntry.title} + + {!options.hideDescription && feedEntry.description && ( + + {feedDescriptionToText(feedEntry.description)} + + )} + {feedEntry.published && } + + + + ); + })} + + )} ); } diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 664d39ce1..8247f19cb 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -98,14 +98,15 @@ export default function SmartHomeEntityStateWidget({ if (entityError && entity === undefined) throw entityError; - const isTiny = isSmartHomeTiny(width, height); + const isAdvanced = displayMode === "advanced"; + const isTiny = isAdvanced ? isSmartHomeTiny(width, height) : width < 128; const advancedAttributes = [ { key: "unit" as const, value: entity?.attributes.unit_of_measurement }, { key: "deviceClass" as const, value: entity?.attributes.device_class }, { key: "icon" as const, value: entity?.attributes.icon }, ].filter(({ value }) => typeof value === "string" && value.length > 0); const displayName = - displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string" + isAdvanced && typeof entity?.attributes.friendly_name === "string" ? entity.attributes.friendly_name : options.displayName; const knownStates = { @@ -133,20 +134,34 @@ export default function SmartHomeEntityStateWidget({ }} >
- - - {state} - {attribute} - - - {displayName} - + + {isAdvanced ? ( + <> + + {state} + {attribute} + + + {displayName} + + + ) : ( + <> + + {displayName} + + + {state} + {attribute} + + + )} {queryErrorLabel && ( {queryErrorLabel} )} - {displayMode === "advanced" && entity && ( + {isAdvanced && entity && ( <> {advancedAttributes.map(({ key, value }) => ( diff --git a/packages/widgets/src/speedtest-tracker/averages.tsx b/packages/widgets/src/speedtest-tracker/averages.tsx index 103bed3fb..08f7dd4ac 100644 --- a/packages/widgets/src/speedtest-tracker/averages.tsx +++ b/packages/widgets/src/speedtest-tracker/averages.tsx @@ -10,12 +10,20 @@ import { formatStatsSpeed } from "./helpers"; import { SectionLabel } from "./section-label"; import { SpeedStatCard } from "./speed-stat-card"; -export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) { +export function AveragesSection({ + stats, + width = 500, + compactSurface = false, +}: { + stats: SpeedtestTrackerStats; + width?: number; + compactSurface?: boolean; +}) { const t = useI18n("widget.speedtestTracker"); return ( - + {t("averages")} · {t("tests", { count: stats.total_results.toString() })} @@ -25,6 +33,7 @@ export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTracke value={formatStatsSpeed(stats.download)} label={t("avgDownload")} compact + legacySurface={compactSurface} /> diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index 74f7258ec..8ccae86e4 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -11,7 +11,7 @@ import { IntegrationErrorIndicator } from "../common/integration-error-indicator import { getUsableWidgetQueryData } from "../common/query-state"; import { WidgetEmptyState } from "../common/empty-state"; import { AveragesSection } from "./averages"; -import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards, getCompactSections } from "./helpers"; +import { combineSpeedtestDashboards, getAvailableSpeedtestDashboards } from "./helpers"; import { LatestResultSection } from "./latest-result"; import { RecentResultsSection } from "./recent-results"; @@ -51,16 +51,19 @@ export default function SpeedtestTrackerWidget({ const advancedSectionWidth = width >= 720 ? width / 2 : width; const latest = showLatestResult && combined.latestResult && ( - + ); const averages = showStats && combined.stats && ( - + ); - const compactSections = getCompactSections(height, { - latest: Boolean(latest), - chart: hasChart, - averages: Boolean(averages), - }); if (displayMode === "advanced") { const sourceColumns = successfulDashboards.length > 1 && width >= 900 ? 2 : 1; @@ -136,15 +139,15 @@ export default function SpeedtestTrackerWidget({ {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} )} - {(compactSections.latest || compactSections.averages) && ( + {(latest || averages) && ( - {compactSections.latest &&
{latest}
} - {compactSections.averages &&
{averages}
} + {latest &&
{latest}
} + {averages &&
{averages}
}
)} - {compactSections.chart && ( + {hasChart && (
- +
)} {noSectionsEnabled && ( diff --git a/packages/widgets/src/speedtest-tracker/latest-result.tsx b/packages/widgets/src/speedtest-tracker/latest-result.tsx index 8359b7cca..f7764736f 100644 --- a/packages/widgets/src/speedtest-tracker/latest-result.tsx +++ b/packages/widgets/src/speedtest-tracker/latest-result.tsx @@ -10,7 +10,15 @@ import { formatResultSpeed } from "./helpers"; import { SectionLabel } from "./section-label"; import { SpeedStatCard } from "./speed-stat-card"; -export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) { +export function LatestResultSection({ + result, + width = 500, + compactSurface = false, +}: { + result: SpeedtestTrackerResult; + width?: number; + compactSurface?: boolean; +}) { const t = useI18n("widget.speedtestTracker"); const locale = useCurrentIntlLocale(); const timestamp = Intl.DateTimeFormat(locale, { @@ -25,7 +33,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest return ( - {t("latestResult")} + {t("latestResult")} {timestamp} @@ -36,18 +44,21 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest color="blue" value={formatResultSpeed(result, "download")} label={t("download")} + legacySurface={compactSurface} /> {result.healthy !== null && ( )} diff --git a/packages/widgets/src/speedtest-tracker/recent-results.tsx b/packages/widgets/src/speedtest-tracker/recent-results.tsx index beb8de735..a640912ba 100644 --- a/packages/widgets/src/speedtest-tracker/recent-results.tsx +++ b/packages/widgets/src/speedtest-tracker/recent-results.tsx @@ -114,30 +114,40 @@ function formatTooltipDate(label: number | string | undefined, locale: string): export function RecentResultsSection({ results, showPingGraph, + uppercaseLabels = false, }: { results: SpeedtestTrackerResult[]; showPingGraph: boolean; + uppercaseLabels?: boolean; }) { const t = useI18n("widget.speedtestTracker"); return ( - {t("recentResults")} + {t("recentResults")}
- - {showPingGraph && } + + {showPingGraph && }
); } -function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult[]; showLabel: boolean }) { +function SpeedSubChart({ + results, + showLabel, + uppercaseLabels, +}: { + results: SpeedtestTrackerResult[]; + showLabel: boolean; + uppercaseLabels: boolean; +}) { const t = useI18n("widget.speedtestTracker"); const { ref, height } = useElementSize(); return (
- {showLabel && {t("recentResultsSpeed")}} + {showLabel && {t("recentResultsSpeed")}}
{height > 0 && }
@@ -145,13 +155,13 @@ function SpeedSubChart({ results, showLabel }: { results: SpeedtestTrackerResult ); } -function PingSubChart({ results }: { results: SpeedtestTrackerResult[] }) { +function PingSubChart({ results, uppercaseLabels }: { results: SpeedtestTrackerResult[]; uppercaseLabels: boolean }) { const t = useI18n("widget.speedtestTracker"); const { ref, height } = useElementSize(); return (
- {t("recentResultsPing")} + {t("recentResultsPing")}
{height > 0 && }
diff --git a/packages/widgets/src/speedtest-tracker/section-label.tsx b/packages/widgets/src/speedtest-tracker/section-label.tsx index ce7e306d2..d18cdc1a5 100644 --- a/packages/widgets/src/speedtest-tracker/section-label.tsx +++ b/packages/widgets/src/speedtest-tracker/section-label.tsx @@ -1,9 +1,15 @@ import type { ReactNode } from "react"; import { Text } from "@mantine/core"; -export function SectionLabel({ children }: { children: ReactNode }) { +export function SectionLabel({ children, uppercase = false }: { children: ReactNode; uppercase?: boolean }) { return ( - + {children} ); diff --git a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx index af475bfbe..7372be195 100644 --- a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx +++ b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx @@ -13,9 +13,17 @@ export interface SpeedStatCardProps { value: string; label: string; compact?: boolean; + legacySurface?: boolean; } -export function SpeedStatCard({ icon: Icon, color, value, label, compact = false }: SpeedStatCardProps) { +export function SpeedStatCard({ + icon: Icon, + color, + value, + label, + compact = false, + legacySurface = false, +}: SpeedStatCardProps) { const { ref, height, width } = useElementSize(); const board = useRequiredBoard(); const isWide = width > height + 20; @@ -23,7 +31,8 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false const surfaceColor = `var(--mantine-color-${color}-filled)`; const accentColor = `var(--mantine-color-${color}-5)`; const surfaceAlpha = compact ? 0.1 : 0.14; - const surfaceBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`; + const modernBackground = `rgb(from ${surfaceColor} r g b / calc(var(--opacity, 1) * ${surfaceAlpha}))`; + const surfaceBackground = legacySurface ? `var(--mantine-color-${color}-light)` : modernBackground; const surfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))"; return ( @@ -32,7 +41,7 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false ref={ref} p={compact ? "xs" : "sm"} radius={board.itemRadius} - withBorder + withBorder={!legacySurface} bg={surfaceBackground} h="100%" style={{ flex: 1, minWidth: 0, borderColor: surfaceBorder }} diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index 9e7cf4d67..557a8cb85 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip } from "@mantine/core"; +import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -28,12 +28,12 @@ const advancedColumnBreakpoints = [ { minimumWidth: 0, columns: 1 }, ] as const; -const getSystemDisksLayout = (width: number, height: number, diskCount: number, isAdvanced: boolean) => { +const getSystemDisksLayout = (width: number, diskCount: number, isAdvanced: boolean) => { if (!isAdvanced) { return { columns: 1, - showSecondaryText: height >= 160 && width >= 260, - showTemperature: height >= 100 && width >= 220, + showSecondaryText: false, + showTemperature: true, }; } @@ -113,6 +113,7 @@ const SystemDiskCard = ({ showTemperature, }: SystemDiskCardProps) => { const board = useRequiredBoard(); + const scheme = useMantineColorScheme(); const t = useI18n("widget.systemDisks"); const valueRef = useRef(null); const [valueFits, setValueFits] = useState(true); @@ -149,6 +150,10 @@ const SystemDiskCard = ({ .join(" · "); const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; + const legacyBackground = scheme.colorScheme === "dark" ? "dark.7" : "gray.1"; + const cardBackground = isAdvanced ? backgroundColor : legacyBackground; + const progressBackground = healthy ? "var(--mantine-color-green-light)" : "var(--mantine-color-red-light)"; + const legacyProgressColor = healthy ? "green" : "red"; return (
- + {deviceName} {integrationName && ( @@ -174,7 +179,11 @@ const SystemDiskCard = ({ {integrationName} )} - + {displayText} {!healthy && {unhealthyLabel}} @@ -195,10 +204,13 @@ const SystemDiskCard = ({ )}
- {showTemperature && (hasTemperature || isAdvanced) ? {temperatureText} : null} + {showTemperature && (hasTemperature || isAdvanced) ? ( + {temperatureText} + ) : null}
@@ -219,7 +231,6 @@ export default function SystemResources({ integrationIds, options, width, - height, displayMode, }: WidgetComponentProps<"systemDisks">) { const t = useI18n("widget.systemDisks"); @@ -247,8 +258,8 @@ export default function SystemResources({ }); const isAdvanced = displayMode === "advanced"; const layout = useMemo( - () => getSystemDisksLayout(width, height, disks.length, isAdvanced), - [disks.length, height, isAdvanced, width], + () => getSystemDisksLayout(width, disks.length, isAdvanced), + [disks.length, isAdvanced, width], ); const queryIndicators = ( diff --git a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx index 7d168fcc7..756c7e65b 100644 --- a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx @@ -11,6 +11,7 @@ export const CombinedNetworkTrafficChart = ({ usageOverTime, hasShadow, labelDisplayMode, + advanced = false, }: { usageOverTime: { up: number; @@ -18,6 +19,7 @@ export const CombinedNetworkTrafficChart = ({ }[]; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down })); const t = useI18n("widget.systemResources.card"); @@ -35,6 +37,7 @@ export const CombinedNetworkTrafficChart = ({ yAxisProps={{ domain: [0, "dataMax"] }} chartType={hasShadow ? "area" : "line"} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { return ( diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index e839f4423..7902b8705 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import type { AreaChartSeries } from "@mantine/charts"; import { AreaChart, LineChart } from "@mantine/charts"; -import { Card, Center, Group, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core"; import { useElementSize, useHover, useMergedRef } from "@mantine/hooks"; import type { TooltipProps, YAxisProps } from "recharts"; @@ -23,6 +23,7 @@ export const CommonChart = ({ yAxisProps, lastValue, chartType = "line", + advanced = false, }: { data: Record[]; dataKey: string; @@ -34,30 +35,32 @@ export const CommonChart = ({ yAxisProps?: Omit; lastValue?: string; chartType?: "line" | "area"; + advanced?: boolean; }) => { const { ref: elementSizeRef, height } = useElementSize(); const theme = useMantineTheme(); + const colorScheme = useComputedColorScheme("light"); const board = useRequiredBoard(); const { hovered, ref: hoverRef } = useHover(); const ref = useMergedRef(elementSizeRef, hoverRef); - const backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; - const borderColor = "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; + const opacity = board.opacity / 100; + let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`; + const cardStyle: CSSProperties = { overflow: "hidden" }; + const chartRootStyle: CSSProperties = { padding: 5, borderRadius: theme.radius[board.itemRadius] }; + if (advanced) { + backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))"; + cardStyle.border = + "1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))"; + chartRootStyle.backgroundColor = backgroundColor; + } const ChartComponent = chartType === "line" ? LineChart : AreaChart; const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon"; const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon"; return ( - + {data.length > 1 && height > 40 && !hovered && ( - {showIcon && 100 ? 20 : 14)} stroke={1.5} />} + {showIcon && ( + 100 ? 20 : 14)} + stroke={1.5} + /> + )} {showText && ( 100 ? "md" : "xs"} fw={"bold"}> {title} @@ -83,7 +92,7 @@ export const CommonChart = ({ )} )} - {data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && ( + {advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
{lastValue} @@ -93,13 +102,19 @@ export const CommonChart = ({ {data.length <= 1 ? (
- {showIcon && 100 ? 20 : 14)} stroke={1.5} />} + {showIcon && ( + 100 ? 20 : 14)} + stroke={1.5} + /> + )} {showText && ( 100 ? "md" : "xs"} fw={"bold"} ta="center"> {title} )} - {lastValue && ( + {advanced && lastValue && ( 100 ? "md" : "xs"} fw={600} ta="center"> {lastValue} @@ -119,13 +134,7 @@ export const CommonChart = ({ withYAxis={false} withDots={false} bg={backgroundColor} - styles={{ - root: { - padding: 5, - borderRadius: theme.radius[board.itemRadius], - backgroundColor, - }, - }} + styles={{ root: chartRootStyle }} tooltipAnimationDuration={200} tooltipProps={{ // Render via portal so the tooltip isn't clipped by the card's overflow:hidden diff --git a/packages/widgets/src/system-resources/chart/cpu-chart.tsx b/packages/widgets/src/system-resources/chart/cpu-chart.tsx index 572a90b51..2922b737a 100644 --- a/packages/widgets/src/system-resources/chart/cpu-chart.tsx +++ b/packages/widgets/src/system-resources/chart/cpu-chart.tsx @@ -10,10 +10,12 @@ export const SystemResourceCPUChart = ({ cpuUsageOverTime, hasShadow, labelDisplayMode, + advanced = false, }: { cpuUsageOverTime: number[]; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage })); @@ -31,6 +33,7 @@ export const SystemResourceCPUChart = ({ chartType={hasShadow ? "area" : "line"} yAxisProps={{ domain: [0, 100] }} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { const value = payload[0] ? Number(payload[0].value) : 0; diff --git a/packages/widgets/src/system-resources/chart/gpu-chart.tsx b/packages/widgets/src/system-resources/chart/gpu-chart.tsx index 40cf6ea2f..51d7ea9e5 100644 --- a/packages/widgets/src/system-resources/chart/gpu-chart.tsx +++ b/packages/widgets/src/system-resources/chart/gpu-chart.tsx @@ -10,10 +10,12 @@ export const SystemResourceGPUChart = ({ gpuUsageOverTime, hasShadow, labelDisplayMode, + advanced = false, }: { gpuUsageOverTime: number[]; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage })); @@ -31,6 +33,7 @@ export const SystemResourceGPUChart = ({ chartType={hasShadow ? "area" : "line"} yAxisProps={{ domain: [0, 100] }} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { const value = payload[0] ? Number(payload[0].value) : 0; diff --git a/packages/widgets/src/system-resources/chart/memory-chart.tsx b/packages/widgets/src/system-resources/chart/memory-chart.tsx index d636b8fc9..187ecb670 100644 --- a/packages/widgets/src/system-resources/chart/memory-chart.tsx +++ b/packages/widgets/src/system-resources/chart/memory-chart.tsx @@ -12,11 +12,13 @@ export const SystemResourceMemoryChart = ({ totalCapacityInBytes, hasShadow, labelDisplayMode, + advanced = false, }: { memoryUsageOverTime: number[]; totalCapacityInBytes: number; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage })); const t = useI18n("widget.systemResources.card"); @@ -35,6 +37,7 @@ export const SystemResourceMemoryChart = ({ title={t("memory")} icon={IconBrain} labelDisplayMode={labelDisplayMode} + advanced={advanced} yAxisProps={{ domain: [0, totalCapacityInBytes] }} lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined} chartType={hasShadow ? "area" : "line"} diff --git a/packages/widgets/src/system-resources/chart/network-traffic.tsx b/packages/widgets/src/system-resources/chart/network-traffic.tsx index 3881923c5..44d81b17e 100644 --- a/packages/widgets/src/system-resources/chart/network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/network-traffic.tsx @@ -12,11 +12,13 @@ export const NetworkTrafficChart = ({ isUp, hasShadow, labelDisplayMode, + advanced = false, }: { usageOverTime: number[]; isUp: boolean; hasShadow: boolean; labelDisplayMode: LabelDisplayModeOption; + advanced?: boolean; }) => { const chartData = usageOverTime.map((usage, index) => ({ index, usage })); const t = useI18n("widget.systemResources.card"); @@ -36,6 +38,7 @@ export const NetworkTrafficChart = ({ lastValue={formatByteRate(Math.round(latest))} chartType={hasShadow ? "area" : "line"} labelDisplayMode={labelDisplayMode} + advanced={advanced} tooltipProps={{ content: ({ payload }) => { const value = payload[0] ? Number(payload[0].value) : 0; diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index f077659bd..e91bc2978 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -68,7 +68,7 @@ export const getVisibleSystemCharts = ({ configuredCharts, hasGpu, hasNetwork, - height, + height: _height, isAdvanced, }: { configuredCharts: readonly SystemChart[]; @@ -81,7 +81,7 @@ export const getVisibleSystemCharts = ({ const availableCharts = selectedCharts.filter( (chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork), ); - return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height)); + return [...availableCharts]; }; export default function SystemResources({ @@ -235,10 +235,7 @@ const SystemCharts = ({ const showNetwork = visibleCharts.includes("network"); const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const chartCount = visibleCharts.length; - const chartColumns = useMemo( - () => (isAdvanced ? getAdvancedChartColumns(width) : 1), - [isAdvanced, width], - ); + const chartColumns = useMemo(() => (isAdvanced ? getAdvancedChartColumns(width) : 1), [isAdvanced, width]); const compactChartGap = 8; const compactRowHeight = 56; const chartHeight = isAdvanced @@ -269,6 +266,7 @@ const SystemCharts = ({ cpuUsageOverTime={items.map((item) => item.cpu)} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> )} @@ -279,6 +277,7 @@ const SystemCharts = ({ totalCapacityInBytes={memoryCapacityInBytes} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> )} @@ -288,6 +287,7 @@ const SystemCharts = ({ gpuUsageOverTime={items.map((item) => item.gpu)} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> )} @@ -299,12 +299,14 @@ const SystemCharts = ({ isUp={false} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> network.up)} isUp hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> ) : ( @@ -313,6 +315,7 @@ const SystemCharts = ({ usageOverTime={networkItems} hasShadow={options.hasShadow} labelDisplayMode={labelDisplayMode} + advanced={isAdvanced} /> ))} diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx index 784dbd1cd..34759ad6a 100644 --- a/packages/widgets/src/timetable/component.tsx +++ b/packages/widgets/src/timetable/component.tsx @@ -65,17 +65,12 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, he const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined; const isAdvanced = displayMode === "advanced"; const isDense = !isAdvanced && (width < 300 || height < 160); - const showTitle = isAdvanced || height >= 112; - const showLine = isAdvanced || width >= 200; - const showPlatform = isAdvanced || width >= 320; return ( - {showTitle && ( - - {t("title", { station: station.label })} - - )} + + {t("title", { station: station.label })} + {compactStaleWarning && displayMode === "compact" && ( )) @@ -144,23 +137,11 @@ interface TimetableEntryView { platform: { name: string; hasChanged: boolean } | null; } -function DepartureRow({ - entry, - dense, - showLine, - showPlatform, - locale, -}: { - entry: TimetableEntryView; - dense: boolean; - showLine: boolean; - showPlatform: boolean; - locale: string; -}) { +function DepartureRow({ entry, dense, locale }: { entry: TimetableEntryView; dense: boolean; locale: string }) { return ( - {showLine && entry.line && ( + {entry.line && ( - {showPlatform && entry.platform && ( + {entry.platform && ( {entry.platform.name} diff --git a/packages/widgets/src/tracearr/component.spec.ts b/packages/widgets/src/tracearr/component.spec.ts index 3a678fc2f..c289f04e8 100644 --- a/packages/widgets/src/tracearr/component.spec.ts +++ b/packages/widgets/src/tracearr/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAdvancedColumnWidth, getCompactSectionVisibility, getTracearrSectionVisibility } from "./component"; +import { getAdvancedColumnWidth, getTracearrSectionVisibility } from "./component"; import { attachTracearrSource } from "./source"; describe("Tracearr source ownership", () => { @@ -30,32 +30,6 @@ describe("Tracearr advanced layout", () => { }); }); -describe("Tracearr compact disclosure", () => { - it("keeps urgent violations visible in a short widget", () => { - expect( - getCompactSectionVisibility({ - height: 160, - showStreams: true, - showViolations: true, - hasViolations: true, - showRecentActivity: true, - }), - ).toEqual({ violations: true, recentActivity: false }); - }); - - it("reveals secondary history as height becomes available", () => { - expect( - getCompactSectionVisibility({ - height: 400, - showStreams: true, - showViolations: true, - hasViolations: false, - showRecentActivity: true, - }), - ).toEqual({ violations: true, recentActivity: true }); - }); -}); - describe("Tracearr advanced disclosure", () => { it("shows all sections even when compact presentation toggles are disabled", () => { expect( diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 74d5542d5..b2793cbc7 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -33,54 +33,27 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is recentActivity: isAdvanced || options.showRecentActivity, }); -export const getCompactSectionVisibility = ({ - height, - showStreams, - showViolations, - hasViolations, - showRecentActivity, -}: { - height: number; - showStreams: boolean; - showViolations: boolean; - hasViolations: boolean; - showRecentActivity: boolean; -}) => ({ - violations: showViolations && (hasViolations || height >= 240 || !showStreams), - recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))), -}); - export default function TracearrWidget({ options, integrationIds, width, - height, displayMode = "compact", }: WidgetComponentProps<"tracearr">) { if (integrationIds.length === 0) { throw new NoIntegrationDataError(); } - return ( - - ); + return ; } interface TracearrContentProps { integrationIds: string[]; options: WidgetComponentProps<"tracearr">["options"]; width: number; - height: number; displayMode: "compact" | "advanced"; } -function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) { +function TracearrContent({ integrationIds, options, width, displayMode }: TracearrContentProps) { const t = useI18n("widget.tracearr"); const dashboardQuery = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds }); const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? []; @@ -157,7 +130,6 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } const sectionVisibility = getTracearrSectionVisibility(options, isAdvanced); const noSectionsEnabled = Object.values(sectionVisibility).every((isVisible) => !isVisible); - const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8; const sourcedStreams = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => attachTracearrSource(dashboard.streams.data, { integrationId, integrationName }), ); @@ -167,15 +139,7 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } const sourcedRecentActivity = successfulDashboards.flatMap(({ integrationId, integrationName, dashboard }) => attachTracearrSource(dashboard.recentActivity?.data ?? [], { integrationId, integrationName }), ); - const streams = isAdvanced ? sourcedStreams : sourcedStreams.slice(0, compactLimit); const showSource = isAdvanced || dashboardData.length > 1; - const compactSections = getCompactSectionVisibility({ - height, - showStreams: sectionVisibility.streams, - showViolations: sectionVisibility.violations, - hasViolations: sourcedViolations.length > 0, - showRecentActivity: sectionVisibility.recentActivity, - }); if (isAdvanced) { const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT; @@ -196,18 +160,18 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } {successfulDashboards.map(({ integrationName }) => integrationName).join(" · ")} {sectionVisibility.stats && ( - + )} {sectionVisibility.streams && ( - + )} {sectionVisibility.violations && ( - + )} {sectionVisibility.recentActivity && ( - + )} @@ -231,12 +195,10 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } {sectionVisibility.stats && ( )} - {compactSections.violations && ( - - )} - {sectionVisibility.streams && } - {compactSections.recentActivity && ( - + {sectionVisibility.streams && } + {sectionVisibility.violations && } + {sectionVisibility.recentActivity && ( + )} {noSectionsEnabled && ( diff --git a/packages/widgets/src/tracearr/recent-activity-section.tsx b/packages/widgets/src/tracearr/recent-activity-section.tsx index b52c5f1cc..011fcde6f 100644 --- a/packages/widgets/src/tracearr/recent-activity-section.tsx +++ b/packages/widgets/src/tracearr/recent-activity-section.tsx @@ -10,16 +10,18 @@ type SourcedTracearrHistorySession = SourcedTracearrItem export function RecentActivityList({ sessions, showSource, + transparent = false, }: { sessions: SourcedTracearrHistorySession[]; showSource: boolean; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); const locale = useCurrentIntlLocale(); return ( - + {t("recentActivity.title")} {sessions.length === 0 ? ( @@ -35,7 +37,7 @@ export function RecentActivityList({ : session.mediaTitle; return ( - + diff --git a/packages/widgets/src/tracearr/stats-section.tsx b/packages/widgets/src/tracearr/stats-section.tsx index 94648306b..d95e8ccf7 100644 --- a/packages/widgets/src/tracearr/stats-section.tsx +++ b/packages/widgets/src/tracearr/stats-section.tsx @@ -8,10 +8,12 @@ export function StatsBar({ stats, summary, width, + transparent = false, }: { stats: TracearrDashboardData["stats"]; summary: TracearrDashboardData["streams"]["summary"]; width: number; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); const cols = width > 400 ? 4 : width > 250 ? 2 : 1; @@ -22,29 +24,43 @@ export function StatsBar({ icon={} label={t("stats.activeStreams")} value={stats.activeStreams} + transparent={transparent} /> } label={t("stats.totalUsers")} value={stats.totalUsers} + transparent={transparent} /> } label={t("stats.transcodes")} value={`${summary.transcodes}/${summary.total}`} + transparent={transparent} /> } label={t("stats.bandwidth")} value={summary.totalBitrate} + transparent={transparent} /> ); } -function StatCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | number }) { +function StatCard({ + icon, + label, + value, + transparent, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + transparent: boolean; +}) { return ( - + {icon} diff --git a/packages/widgets/src/tracearr/streams-section.tsx b/packages/widgets/src/tracearr/streams-section.tsx index 6a4096146..8d4388491 100644 --- a/packages/widgets/src/tracearr/streams-section.tsx +++ b/packages/widgets/src/tracearr/streams-section.tsx @@ -13,10 +13,12 @@ export function StreamsList({ streams, width, showSource, + transparent = false, }: { streams: SourcedTracearrStream[]; width: number; showSource: boolean; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); @@ -31,7 +33,13 @@ export function StreamsList({ return ( {streams.map((stream) => ( - + ))} ); @@ -41,10 +49,12 @@ function StreamCard({ stream, compact, showSource, + transparent, }: { stream: SourcedTracearrStream; compact: boolean; showSource: boolean; + transparent: boolean; }) { const t = useI18n("widget.tracearr"); const progressPercent = @@ -65,7 +75,12 @@ function StreamCard({ : null; return ( - + {stream.posterUrl && ( ; export function ViolationsList({ violations, showSource, + transparent = false, }: { violations: SourcedTracearrViolation[]; showSource: boolean; + transparent?: boolean; }) { const t = useI18n("widget.tracearr"); const locale = useCurrentIntlLocale(); return ( - + {t("violations.title")} {violations.length === 0 ? ( @@ -30,7 +32,7 @@ export function ViolationsList({ ) : ( {violations.map((violation) => ( - + { }); describe("Traefik advanced disclosure", () => { - test("shows every protocol regardless of compact toggles and height", () => { - expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, 100, true)).toEqual(["http", "tcp", "udp"]); + test("shows every protocol in advanced mode regardless of compact toggles", () => { + expect(getTraefikProtocolKeys({ showTcp: false, showUdp: false }, true)).toEqual(["http", "tcp", "udp"]); }); test("shows every unique entry point regardless of compact width limits", () => { diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx index b2320f4b6..725ce5c3d 100644 --- a/packages/widgets/src/traefik/component.tsx +++ b/packages/widgets/src/traefik/component.tsx @@ -79,12 +79,11 @@ export const getTraefikSourceDetails = (sources: readonly TraefikDashboardSource export function getTraefikProtocolKeys( options: Pick["options"], "showTcp" | "showUdp">, - height: number, isAdvanced: boolean, ): ProtocolKey[] { const protocols: ProtocolKey[] = ["http"]; - if (isAdvanced || (options.showTcp && height >= 220)) protocols.push("tcp"); - if (isAdvanced || (options.showUdp && height >= 300)) protocols.push("udp"); + if (isAdvanced || options.showTcp) protocols.push("tcp"); + if (isAdvanced || options.showUdp) protocols.push("udp"); return protocols; } @@ -105,7 +104,6 @@ function TraefikWidgetContent({ integrationIds, options, width, - height, displayMode = "compact", }: WidgetComponentProps<"traefik">) { const t = useI18n("widget.traefik"); @@ -143,7 +141,7 @@ function TraefikWidgetContent({ combined.failedEndpoints.length; const totalWarnings = getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp); - const protocolKeys = getTraefikProtocolKeys(options, height, isAdvanced); + const protocolKeys = getTraefikProtocolKeys(options, isAdvanced); const entryPoints = getVisibleTraefikEntryPoints(combined.entryPoints, width, isAdvanced); const displayedVersion = successfulData.length === 1 ? successfulData[0]?.dashboard.version : null; @@ -194,7 +192,7 @@ function TraefikWidgetContent({ ))}
- {(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (isAdvanced || height >= 340) && ( + {(isAdvanced || options.showEntryPoints) && entryPoints.length > 0 && (
{entryPoints.map((entryPoint) => ( diff --git a/packages/widgets/src/umami/umami-layout.spec.ts b/packages/widgets/src/umami/umami-layout.spec.ts index b03f5af51..0875696b0 100644 --- a/packages/widgets/src/umami/umami-layout.spec.ts +++ b/packages/widgets/src/umami/umami-layout.spec.ts @@ -6,13 +6,9 @@ describe("Umami layout", () => { test.each([ { width: 260, height: 119, property: "isDense" as const, expected: true }, { width: 260, height: 120, property: "isDense" as const, expected: false }, - { width: 260, height: 139, property: "showXAxis" as const, expected: false }, - { width: 260, height: 140, property: "showXAxis" as const, expected: true }, - { width: 260, height: 95, property: "showSecondaryStats" as const, expected: false }, - { width: 260, height: 96, property: "showSecondaryStats" as const, expected: true }, - { width: 259, height: 150, property: "showDetailedStats" as const, expected: false }, - { width: 260, height: 149, property: "showDetailedStats" as const, expected: false }, - { width: 260, height: 150, property: "showDetailedStats" as const, expected: true }, + { width: 100, height: 80, property: "showXAxis" as const, expected: true }, + { width: 100, height: 80, property: "showSecondaryStats" as const, expected: true }, + { width: 100, height: 80, property: "showDetailedStats" as const, expected: true }, ])("sets $property to $expected at $width x $height", ({ width, height, property, expected }) => { expect(getUmamiLayout({ width, height, displayMode: "compact" })[property]).toBe(expected); }); diff --git a/packages/widgets/src/umami/umami-layout.ts b/packages/widgets/src/umami/umami-layout.ts index 05f02140f..37d8124f0 100644 --- a/packages/widgets/src/umami/umami-layout.ts +++ b/packages/widgets/src/umami/umami-layout.ts @@ -8,9 +8,9 @@ export const getUmamiLayout = ({ width, height, displayMode }: UmamiLayoutInput) const isAdvanced = displayMode === "advanced"; return { isDense: !isAdvanced && height < 120, - showXAxis: isAdvanced || height >= 140, - showSecondaryStats: isAdvanced || height >= 96, - showDetailedStats: isAdvanced || (width >= 260 && height >= 150), + showXAxis: true, + showSecondaryStats: true, + showDetailedStats: true, stackAdvancedContent: isAdvanced && width < 900, }; }; diff --git a/packages/widgets/src/uptime-kuma/aggregate.spec.ts b/packages/widgets/src/uptime-kuma/aggregate.spec.ts index 4982f5331..c4aaf9f18 100644 --- a/packages/widgets/src/uptime-kuma/aggregate.spec.ts +++ b/packages/widgets/src/uptime-kuma/aggregate.spec.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import { aggregateUptimeKumaDashboards } from "./aggregate"; -import { getCompactStatLimit, getCompactStatPriority } from "./component"; describe("aggregateUptimeKumaDashboards", () => { it("weights uptime by monitors instead of integrations", () => { @@ -30,26 +29,3 @@ describe("aggregateUptimeKumaDashboards", () => { expect(combined.averageUptimePercent).toBe(25); }); }); - -describe("getCompactStatLimit", () => { - it("reserves compact height for the uptime summary", () => { - expect(getCompactStatLimit(150, true, 4)).toBe(0); - expect(getCompactStatLimit(220, true, 4)).toBe(2); - }); - - it("uses the space for metrics when the summary is disabled", () => { - expect(getCompactStatLimit(100, false, 4)).toBe(2); - expect(getCompactStatLimit(240, false, 4)).toBe(4); - }); -}); - -describe("getCompactStatPriority", () => { - it("puts active failures before healthy totals", () => { - expect(getCompactStatPriority("downCount", 2)).toBeLessThan(getCompactStatPriority("totalMonitors", 10)); - expect(getCompactStatPriority("pausedCount", 1)).toBeLessThan(getCompactStatPriority("upCount", 8)); - }); - - it("moves zero-value problem states behind healthy totals", () => { - expect(getCompactStatPriority("downCount", 0)).toBeGreaterThan(getCompactStatPriority("upCount", 8)); - }); -}); diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index d21a89b13..908d8ed52 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -79,25 +79,6 @@ const heroVariantByRing = { false: classes.heroTextOnly, } as const; -export function getCompactStatLimit(height: number, showHero: boolean, statCount: number): number { - if (showHero) { - if (height < 180) return 0; - if (height < 260) return Math.min(2, statCount); - if (height < 340) return Math.min(4, statCount); - } else { - if (height < 130) return Math.min(2, statCount); - if (height < 220) return Math.min(4, statCount); - } - return statCount; -} - -export function getCompactStatPriority(statKey: keyof typeof statIcons, value: number): number { - if (statKey === "downCount") return value > 0 ? 0 : 4; - if (statKey === "pausedCount") return value > 0 ? 1 : 5; - if (statKey === "totalMonitors") return 2; - return 3; -} - export default function UptimeKumaWidget({ integrationIds, options, @@ -158,16 +139,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode .map(([, statKey]) => statKey); const showHero = isAdvanced || options.showAverageUptime; - const showRing = isAdvanced || (options.showUptimeRing && width >= 170 && height >= 110); - const prioritizedStatKeys = isAdvanced - ? enabledStatKeys - : enabledStatKeys.toSorted( - (left, right) => getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), - ); - const visibleStatKeys = prioritizedStatKeys.slice( - 0, - isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), - ); + const showRing = isAdvanced || options.showUptimeRing; + const visibleStatKeys = enabledStatKeys; const hasContent = showHero || visibleStatKeys.length > 0; const heroLayoutClass = @@ -227,24 +200,6 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode })}
)} - {displayMode === "compact" && height >= 300 && combined.downCount > 0 && ( - - {monitors - .filter((monitor) => monitor.status === "down") - .slice(0, 3) - .map((monitor) => ( - - - {monitor.name} - - - {t("status.down")} ·{" "} - {monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`} - - - ))} - - )}
);