diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89aa72bba..39d2c4904 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,8 +320,12 @@ jobs: publish-preview: name: Publish multi-platform preview image - needs: [preview-amd64, preview-arm64] + needs: [container, preview-amd64, preview-arm64] if: | + !cancelled() && + needs.container.result == 'success' && + needs.preview-amd64.result == 'success' && + needs.preview-arm64.result == 'success' && ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || @@ -375,9 +379,6 @@ jobs: ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }} run: | tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}") - if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then - tags+=(-t "${PREVIEW_IMAGE}:pr-6545") - fi docker buildx imagetools create "${tags[@]}" \ "${PREVIEW_IMAGE}@${AMD64_DIGEST}" \ "${PREVIEW_IMAGE}@${ARM64_DIGEST}" @@ -386,9 +387,6 @@ jobs: if: "!env.ACT" run: | tags=("${PREVIEW_TAG}") - if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then - tags+=("pr-6545") - fi for tag in "${tags[@]}"; do image="${PREVIEW_IMAGE}:${tag}" manifest="$(docker buildx imagetools inspect "$image" --raw)" @@ -435,6 +433,14 @@ jobs: core.info(`No package version found with tag "${tag}"`); return; } + const targetTags = target.metadata?.container?.tags ?? []; + const extraTags = targetTags.filter((targetTag) => targetTag !== tag); + if (extraTags.length > 0) { + core.setFailed( + `Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`, + ); + return; + } await github.rest.packages.deletePackageVersionForOrg({ package_type: "container", package_name: packageName, diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 371826598..8b72e3bd6 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference) Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key. You can obtain API keys from the "Authentication" tab at the top of the page. API keys will not expire and can be used indefinitely. +When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the +creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation +instead of opening a separate dialog. ### API Key Format @@ -115,6 +118,8 @@ use access for each newly linked integration; unchanged links remain editable th Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. +The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. + Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. ## Docker action payload migration diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 11d1ba271..3fa95d2d4 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -34,9 +34,11 @@ arrow keys to move through the results and press `Enter` to open the selected bo ## Create a new board -When creating a board, the availability of the board name will be checked for you. +Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board +details without opening a dialog or leaving the board list. The availability of the board name is checked as you type. -![](./img/create-board-modal.png) +The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that +board, and create the copy without navigating to a separate page. ### Name @@ -56,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. +Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the +same menu item. No separate confirmation dialog is opened. + ## Board settings You navigate to the board settings by clicking on these three dots: @@ -405,8 +410,7 @@ nor the integrations the user does not have access to. Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board. Duplicating a board that contains Custom Widgets also requires an administrator. You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board". - -![](./img/duplicate-board-modal.png) +The name form expands below that board card and can be cancelled without leaving the list. ## Setting boards as your home board diff --git a/apps/docs/docs/management/certificates/index.mdx b/apps/docs/docs/management/certificates/index.mdx index aa843bfe0..0c2b9853a 100644 --- a/apps/docs/docs/management/certificates/index.mdx +++ b/apps/docs/docs/management/certificates/index.mdx @@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`. ## Add a certificate -To add a certificate click on the "Add certificate" button. +To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page. ![](./img/add-button.png) -Then select the certificate file you want to add. - -![](./img/add-modal.png) - -And click the "Add" button. +Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without +leaving the page. ## Delete a certificate @@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card. ![](./img/card.png) -Then confirm the deletion. +The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened. ## Managing it through file system diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 33dcfed89..133c29c4a 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work. Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can -delete submissions. Community moderation is separate from Homarr administration and is not a security boundary. +delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a +dialog. Community moderation is separate from Homarr administration and is not a security boundary. Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr performs definitive validation again at install time. diff --git a/apps/docs/docs/management/settings/index.mdx b/apps/docs/docs/management/settings/index.mdx index 215ea1bd5..f45fabdc5 100644 --- a/apps/docs/docs/management/settings/index.mdx +++ b/apps/docs/docs/management/settings/index.mdx @@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards. You can set the default color scheme for users without their own color scheme. +## Branding + +Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings. + +### Identity + +- **Application name** replaces the default Homarr name in page titles and branded surfaces. +- **Logo** is used in the header and other application branding. +- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo. + +### Colors and corners + +Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements. + +### Sign-in page + +You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting. + +Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page. + ## Culture ### Default language diff --git a/apps/docs/docs/management/users/index.mdx b/apps/docs/docs/management/users/index.mdx index 371ec61a7..9c32ba56f 100644 --- a/apps/docs/docs/management/users/index.mdx +++ b/apps/docs/docs/management/users/index.mdx @@ -108,7 +108,7 @@ However, you can create as many groups as you'd like. ### Creating a group Click on the button `New group` to create a new one. -Homarr will prompt you for a group name. +The group-name form expands above the group list and can be closed without leaving the page. ### Managing a group @@ -259,7 +259,8 @@ An invitation link can be used indefinitely. ### Creating an invitation -Click on the button: +Select the create-invitation button. The expiration form expands above the invitation table instead of opening a +dialog or navigating to another page: ![](./img/create-invitation-button.png) @@ -269,12 +270,12 @@ The default is 4 hours in the future. ![](./img/invitation-expiry-date.png) -Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied: +Next, click `Create` and the same section shows the ID, Token and a link that can be copied: ![](./img/create-invitation-confirmation.png) Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again. -Clicking the `Copy & close` button will copy the URL to your clipboard. +Use **Copy** to copy the URL, then close the section when you are finished. ### Self-onboarding @@ -293,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt The link will become invalid. If the invitation expires, the link will also expire. +The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog. 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 and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. 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/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 5f410a7f3..040e08c31 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai - **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni +Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply. + ### Configuration 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..e66344c85 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, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported. ### 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..f016c234b 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 while adapting dense rows to the available space. ### 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/package.json b/apps/nextjs/package.json index 16392ff8d..f5c4794b7 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -72,6 +72,7 @@ "@rexa-developer/tiks": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", + "@tanstack/query-sync-storage-persister": "catalog:", "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 5c18c31f6..d7324e679 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -1,20 +1,17 @@ import { dehydrate, QueryClient } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import { persistQueryClientRestore } from "@tanstack/react-query-persist-client"; -import { parse, stringify } from "superjson"; import { afterEach, describe, expect, test, vi } from "vitest"; import { - createSessionQueryPersister, + createSessionQueryPersistence, getQueryPersistenceStorageKey, queryPersistenceBuster, - queryPersistenceMaxDataBytes, shouldPersistDashboardQuery, } from "./query-persistence"; -const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => { +const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => { const queryClient = new QueryClient(); - queryClient.setQueryData(queryKey, data, { updatedAt }); + queryClient.setQueryData(queryKey, data); const query = queryClient.getQueryCache().find({ queryKey }); if (!query) throw new Error("Expected query to be present"); return { query, queryClient }; @@ -37,314 +34,98 @@ const createMemoryStorage = (): Storage => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - window.sessionStorage.clear(); }); -describe("widget query persistence", () => { - test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => { - const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); +describe("dashboard query persistence", () => { + test("keeps usable dashboard data and skips volatile or unrelated queries", () => { + const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 }); const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]); - const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" }); - const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], { - value: "x".repeat(queryPersistenceMaxDataBytes + 1), + const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []); + const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], { + apiKey: "secret", }); + const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const pendingClient = new QueryClient(); const pending = pendingClient.getQueryCache().build(pendingClient, { queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], queryFn: () => Promise.resolve([]), }); + const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []); + failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" }); expect(shouldPersistDashboardQuery(widget.query)).toBe(true); expect(shouldPersistDashboardQuery(supporting.query)).toBe(true); - expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(ping.query)).toBe(false); - expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); + expect(shouldPersistDashboardQuery(beszel.query)).toBe(false); + expect(shouldPersistDashboardQuery(customApi.query)).toBe(false); + expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(pending)).toBe(false); + expect(shouldPersistDashboardQuery(failed.query)).toBe(true); }); - test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { - expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a")); - expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b")); - expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a")); - - const userAKey = getQueryPersistenceStorageKey("user-a"); - window.sessionStorage.setItem(userAKey, "a"); - createSessionQueryPersister("user-a").removeClient(); - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - }); - - test("evicts every other scope so signing out cannot leave data behind", () => { - const userAKey = getQueryPersistenceStorageKey("user-a"); - const anonymousKey = getQueryPersistenceStorageKey(null); - window.sessionStorage.setItem(userAKey, "a"); - window.sessionStorage.setItem(anonymousKey, "anonymous"); - window.sessionStorage.setItem("unrelated", "kept"); - - createSessionQueryPersister("user-b"); - - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - expect(window.sessionStorage.getItem(anonymousKey)).toBeNull(); - expect(window.sessionStorage.getItem("unrelated")).toBe("kept"); - }); - - test("never restores another session scope", async () => { - const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; - const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 }); - const userAPersister = createSessionQueryPersister("user-a"); - userAPersister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - userAPersister.flush(); - expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); - - expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); - }); - - test("stops persisting once the provider tears the cache down", async () => { - const storage = createMemoryStorage(); - const persister = createSessionQueryPersister("user-a", storage); - const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { - temp: 21, - }); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - persister.stop(); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); - }); - - test("removes the cache instead of storing an empty one", () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - window.sessionStorage.setItem(key, "stale"); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect(window.sessionStorage.getItem(key)).toBeNull(); - }); - - test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { + test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => { vi.useFakeTimers(); - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { + const storage = createMemoryStorage(); + const userA = createSessionQueryPersistence("user-a", storage); + const userB = createSessionQueryPersistence("user-b", storage); + const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { startsAt: new Date("2026-08-22T12:00:00.000Z"), }); - query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" }); - expect(shouldPersistDashboardQuery(query)).toBe(true); + const cachedQuery = queryClient.getQueryCache().find({ + queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], + }); + if (!cachedQuery) throw new Error("Expected cached query to be present"); + cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" }); + queryClient.getMutationCache().build( + queryClient, + { mutationKey: ["integration", "create"], mutationFn: async () => undefined }, + { + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isPaused: true, + status: "pending", + submittedAt: Date.now(), + variables: { apiKey: "secret" }, + }, + ); const persistedClient: PersistedClient = { timestamp: Date.now(), buster: queryPersistenceBuster, - clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), + clientState: dehydrate(queryClient, userA.dehydrateOptions), }; - persister.persistClient(persistedClient); - persister.flush(); - const restored = await persister.restoreClient(); - const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; - expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z")); + expect(persistedClient.clientState.mutations).toHaveLength(0); + + await userA.persister.persistClient(persistedClient); + await vi.advanceTimersByTimeAsync(1_000); + + const restored = await userA.persister.restoreClient(); + expect(restored?.clientState.queries[0]?.state.data).toEqual({ + startsAt: new Date("2026-08-22T12:00:00.000Z"), + }); expect(restored?.clientState.queries[0]?.state.status).toBe("success"); expect(restored?.clientState.queries[0]?.state.error).toBeNull(); - expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); - expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + expect(await userB.persister.restoreClient()).toBeUndefined(); - window.sessionStorage.setItem(key, "not-valid-superjson"); - expect(persister.restoreClient()).toBeUndefined(); - expect(window.sessionStorage.getItem(key)).toBeNull(); + await userA.persister.removeClient(); + expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull(); }); - test("sanitizes structurally valid but unsafe stored state before hydration", async () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const { queryClient } = createSuccessfulQuery( - [["widget", "calendar", "findAllEvents"], { type: "query" }], - [{ title: "safe" }], - ); - const validQuery = dehydrate(queryClient).queries[0]; - if (!validQuery) throw new Error("Expected a dehydrated query"); - - window.sessionStorage.setItem( - key, - stringify({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [{ state: { status: "pending" } }], - queries: [ - { - ...validQuery, - state: { - ...validQuery.state, - error: new Error("private refresh error"), - fetchStatus: "fetching", - status: "error", - }, - }, - { - ...validQuery, - queryHash: "unrelated", - queryKey: [["board", "getBoardByName"], { type: "query" }], - }, - { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, - { - ...validQuery, - queryHash: "missing-timestamp", - state: { ...validQuery.state, dataUpdatedAt: undefined }, - }, - ], - }, - }), - ); - - const restored = await createSessionQueryPersister(scope).restoreClient(); - expect(restored?.clientState.mutations).toEqual([]); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.state.status).toBe("success"); - expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle"); - expect(restored?.clientState.queries[0]?.state.error).toBeNull(); - }); - - test.each([ - ["expired", 1, queryPersistenceBuster], - ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"], - ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster], - ])("discards %s cache entries", async (_case, timestamp, storedBuster) => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z")); - const scope = "user-a"; - const persister = createSessionQueryPersister(scope); - const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { - temp: 21, - }); - persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); - persister.flush(); - - const restoredClient = new QueryClient(); - await persistQueryClientRestore({ - queryClient: restoredClient, - persister, - maxAge: 5 * 60 * 1_000, - buster: queryPersistenceBuster, - }); - - expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined(); - expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); - }); - - test("quota retry writes the cache after evicting the oldest query", async () => { - const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1); - const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2); - const storage = createMemoryStorage(); - const setItem = storage.setItem.bind(storage); - const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => { - const client = parse(value); - if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError"); - setItem(key, value); - }); - const persistedClient: PersistedClient = { - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [], - queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], - }, - }; - const persister = createSessionQueryPersister("user-a", storage); - - persister.persistClient(persistedClient); - persister.flush(); - - expect(setItemSpy).toHaveBeenCalledTimes(2); - const restored = await persister.restoreClient(); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]); - }); - - test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => { - vi.useFakeTimers(); - const storage = createMemoryStorage(); - const setItem = vi.spyOn(storage, "setItem"); - const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; - const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1); - const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2); - const persister = createSessionQueryPersister("user-a", storage); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(first.queryClient), - }); - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(latest.queryClient), - }); - - expect(setItem).not.toHaveBeenCalled(); - persister.flush(); - expect(setItem).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(0); - expect(setItem).toHaveBeenCalledOnce(); - - const restored = await persister.restoreClient(); - expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 }); - }); - - test("treats blocked storage operations as an empty cache", async () => { - const blockedStorage = { - getItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - removeItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - setItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - } as unknown as Storage; - const persister = createSessionQueryPersister("blocked", blockedStorage); - const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 }); - - expect(persister.restoreClient()).toBeUndefined(); - expect(() => { - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - }).not.toThrow(); - expect(() => persister.removeClient()).not.toThrow(); - }); - - test("treats an unavailable session storage getter as an empty cache", async () => { - vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => { + test("treats unavailable session storage as an empty cache", async () => { + const blockedStorage = createMemoryStorage(); + blockedStorage.getItem = () => { throw new DOMException("blocked", "SecurityError"); - }); + }; + blockedStorage.removeItem = () => { + throw new DOMException("blocked", "SecurityError"); + }; - expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); - expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined(); + const persistence = createSessionQueryPersistence("blocked", blockedStorage); + expect(await persistence.persister.restoreClient()).toBeUndefined(); + expect(() => persistence.persister.removeClient()).not.toThrow(); }); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index e5145ff5c..dbfd90c2f 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,13 +1,12 @@ import type { QueryKey } from "@tanstack/react-query"; -import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { removeOldestQuery } from "@tanstack/react-query-persist-client"; +import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client"; import { parse, stringify } from "superjson"; -import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; +import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; -export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; -export const queryPersistenceMaxDataBytes = 256 * 1024; -export const queryPersistenceBuster = "v2-session-superjson"; +export const queryPersistenceBuster = "v6-dashboard-data"; const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; @@ -16,95 +15,33 @@ export const getQueryPersistenceStorageKey = (scope: string | null) => interface PersistableQuery { queryKey: QueryKey; - state: { - data: unknown; - dataUpdatedAt: number; - }; + state: { data: unknown; status: string }; } -export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; - -const textEncoder = new TextEncoder(); -const budgetVerdictCache = new WeakMap(); - -const measureFitsBudget = (data: unknown) => { - try { - return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes; - } catch { - return false; - } -}; - -const fitsStorageBudget = (data: unknown) => { - if (typeof data !== "object" || data === null) return measureFitsBudget(data); - - const cached = budgetVerdictCache.get(data); - if (cached !== undefined) return cached; - - const verdict = measureFitsBudget(data); - budgetVerdictCache.set(data, verdict); - return verdict; -}; - -const holdsDashboardData = (query: PersistableQuery) => - query.state.data !== undefined && - Number.isFinite(query.state.dataUpdatedAt) && - query.state.dataUpdatedAt > 0 && - isWidgetDataQueryKey(query.queryKey); - export const shouldPersistDashboardQuery = (query: PersistableQuery) => - holdsDashboardData(query) && fitsStorageBudget(query.state.data); + query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey); -type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; - -const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ - ...query, - state: { - ...query.state, - error: null, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchFailureCount: 0, - fetchFailureReason: null, - fetchStatus: "idle", - isInvalidated: true, - status: "success", - }, -}); - -const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ - ...client, - clientState: { - mutations: [], - queries: client.clientState.queries.map(sanitizePersistedQuery), - }, -}); - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const isRestorableQuery = (value: unknown): value is DehydratedQuery => { - if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false; - if (!isRecord(value.state) || typeof value.state.status !== "string") return false; - - return holdsDashboardData(value as unknown as PersistableQuery); -}; - -const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => { - if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined; - if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined; - - return { - timestamp: value.timestamp as number, - buster: value.buster, +const serializePersistedClient = (client: PersistedClient) => + stringify({ + ...client, clientState: { - mutations: [], - queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), + ...client.clientState, + queries: client.clientState.queries.map((query) => ({ + ...query, + state: { + ...query.state, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + status: "success", + }, + })), }, - }; -}; + }); -const getSessionStorage = (): Storage | undefined => { +const getSessionStorage = () => { if (typeof window === "undefined") return undefined; try { @@ -114,113 +51,47 @@ const getSessionStorage = (): Storage | undefined => { } }; -const removeStorageItem = (storage: Storage | undefined, key: string) => { - try { - storage?.removeItem(key); - } catch { - return undefined; - } -}; - -const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => { - if (!storage) return; - - try { - const foreignKeys: string[] = []; - for (let index = 0; index < storage.length; index += 1) { - const storedKey = storage.key(index); - if (storedKey === null || storedKey === activeKey) continue; - if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey); - } - - for (const foreignKey of foreignKeys) storage.removeItem(foreignKey); - } catch { - return undefined; - } -}; - -export const createSessionQueryPersister = ( - scope: string | null, - storage: Storage | undefined = getSessionStorage(), -): SessionQueryPersister => { - const key = getQueryPersistenceStorageKey(scope); - removeForeignScopes(storage, key); - let pendingClient: PersistedClient | undefined; - let persistTimer: ReturnType | undefined; - let stopped = false; - - const persistNow = (client: PersistedClient) => { - if (!storage) return; - let candidate: PersistedClient | undefined = sanitizePersistedClient(client); - let errorCount = 0; - - if (candidate.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return; - } - - while (candidate) { - try { - storage.setItem(key, stringify(candidate)); - return; - } catch (error) { - errorCount += 1; - candidate = removeOldestQuery({ - persistedClient: candidate, - error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"), - errorCount, - }); - } - } - }; - - const clearPersistTimer = () => { - if (persistTimer === undefined) return; - clearTimeout(persistTimer); - persistTimer = undefined; - }; - - const flush = () => { - clearPersistTimer(); - const client = pendingClient; - pendingClient = undefined; - if (client) persistNow(client); - }; +const protectStorage = (storage: Storage | undefined) => { + if (!storage) return undefined; return { - persistClient(client) { - if (!storage || stopped) return; - pendingClient = client; - if (persistTimer !== undefined) return; - persistTimer = setTimeout(flush, 0); - }, - restoreClient() { + getItem(key: string) { try { - const storedValue = storage?.getItem(key); - if (!storedValue) return undefined; - - const client = sanitizeRestoredClient(parse(storedValue)); - if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return undefined; - } - - return client; + return storage.getItem(key); + } catch { + return null; + } + }, + setItem(key: string, value: string) { + storage.setItem(key, value); + }, + removeItem(key: string) { + try { + storage.removeItem(key); } catch { - removeStorageItem(storage, key); return undefined; } }, - removeClient() { - clearPersistTimer(); - pendingClient = undefined; - removeStorageItem(storage, key); - }, - flush, - stop() { - stopped = true; - clearPersistTimer(); - pendingClient = undefined; - }, }; }; + +export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"]; + +export const createSessionQueryPersistence = ( + scope: string | null, + storage: Storage | undefined = getSessionStorage(), +): SessionQueryPersistence => ({ + persister: createSyncStoragePersister({ + storage: protectStorage(storage), + key: getQueryPersistenceStorageKey(scope), + serialize: serializePersistedClient, + deserialize: parse, + retry: removeOldestQuery, + }), + maxAge: queryCacheDefaultGcTimeMs, + buster: queryPersistenceBuster, + dehydrateOptions: { + shouldDehydrateMutation: () => false, + shouldDehydrateQuery: shouldPersistDashboardQuery, + }, +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index e46a1a3d8..7da75639d 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryCache, QueryClient } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { @@ -25,6 +25,8 @@ import type { AppRouter } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { dashboardSupportingQueryPolicies, + isTrpcForbiddenError, + isWidgetDataQueryKey, queryCacheDefaultGcTimeMs, queryCacheDefaultRefetchIntervalMs, queryCacheDefaultStaleTimeMs, @@ -36,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; -import { - createSessionQueryPersister, - queryPersistenceBuster, - queryPersistenceMaxAgeMs, - shouldPersistDashboardQuery, -} from "./query-persistence"; -import type { SessionQueryPersister } from "./query-persistence"; +import { createSessionQueryPersistence } from "./query-persistence"; +import type { SessionQueryPersistence } from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -75,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { const { data: session } = useSession(); const sessionQueryScope = getSessionQueryScope(session); const [initialSessionQueryScope] = useState(() => sessionQueryScope); - const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope)); + const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope)); const handleScopeChange = useCallback(() => { - void queryPersister.removeClient(); + void queryPersistence.persister.removeClient(); reloadPage(); - }, [queryPersister]); + }, [queryPersistence]); return ( - {children} + {children} ); } @@ -96,8 +93,8 @@ const reloadPage = () => window.location.reload(); const ScopedTRPCReactProvider = ({ children, - queryPersister, -}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { + queryPersistence, +}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => { const wsClient = useMemo( () => createWSClient({ @@ -114,6 +111,13 @@ const ScopedTRPCReactProvider = ({ ); const [queryClient] = useState(() => { const client = new QueryClient({ + queryCache: new QueryCache({ + onError(error, query) { + if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return; + void queryPersistence.persister.removeClient(); + query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true }); + }, + }), defaultOptions: { queries: { staleTime: queryCacheDefaultStaleTimeMs, @@ -156,15 +160,6 @@ const ScopedTRPCReactProvider = ({ return client; }); - useEffect(() => { - const flush = () => queryPersister.flush(); - window.addEventListener("pagehide", flush); - return () => { - window.removeEventListener("pagehide", flush); - flush(); - queryPersister.stop(); - }; - }, [queryPersister]); useEffect(() => () => queryClient.clear(), [queryClient]); const trpcClient = useMemo(() => { @@ -215,15 +210,7 @@ const ScopedTRPCReactProvider = ({ return ( - + {children} {process.env.NODE_ENV === "development" && } diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx index 9342a78d0..645d25058 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_danger.tsx @@ -1,15 +1,16 @@ "use client"; -import { useCallback } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@mantine/core"; +import { Box, Button, Group, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { useZodForm } from "@homarr/form"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; +import { boardRenameSchema } from "@homarr/validation/board"; -import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"; export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { @@ -17,70 +18,70 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: const t = useI18n("board.setting"); const tCommonAction = useI18n("common.action"); const router = useRouter(); - const { openConfirmModal } = useConfirmModal(); - const { openModal } = useModalAction(BoardRenameModal); + const utils = clientApi.useUtils(); + const [isRenaming, setIsRenaming] = useState(false); + const renameInputRef = useRef(null); + const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), { + initialValues: { + name: board.name, + }, + }); + const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({ + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, + }); const { mutate: changeVisibility, isPending: isChangeVisibilityPending } = clientApi.board.changeBoardVisibility.useMutation(); const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation(); - const utils = clientApi.useUtils(); const visibility = board.isPublic ? "public" : "private"; - const onRenameClick = useCallback( - () => - openModal({ + useEffect(() => { + if (isRenaming) renameInputRef.current?.focus(); + }, [isRenaming]); + + const handleRename = renameForm.onSubmit((values) => { + renameBoard( + { id: board.id, name: values.name }, + { + onSuccess() { + router.push(`/boards/${values.name}/settings`); + }, + }, + ); + }); + + const cancelRename = () => { + renameForm.reset(); + setIsRenaming(false); + }; + + const handleVisibilityChange = () => { + changeVisibility( + { id: board.id, - previousName: board.name, - onSuccess: (name) => router.push(`/boards/${name}/settings`), - }), - [board.id, board.name, router, openModal], - ); - - const onVisibilityClick = useCallback(() => { - openConfirmModal({ - title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`), - children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`), - onConfirm: () => { - changeVisibility( - { - id: board.id, - visibility: visibility === "public" ? "private" : "public", - }, - { - onSettled() { - void utils.board.getBoardByName.invalidate({ name: board.name }); - void utils.board.getHomeBoard.invalidate(); - }, - }, - ); + visibility: visibility === "public" ? "private" : "public", }, - }); - }, [ - board.id, - board.name, - changeVisibility, - t, - utils.board.getBoardByName, - utils.board.getHomeBoard, - visibility, - openConfirmModal, - ]); - - const onDeleteClick = useCallback(() => { - openConfirmModal({ - title: t("section.dangerZone.action.delete.confirm.title"), - children: t("section.dangerZone.action.delete.confirm.description"), - onConfirm: () => { - deleteBoard( - { id: board.id }, - { - onSettled: () => { - router.push("/"); - }, - }, - ); + { + onSettled() { + void utils.board.getBoardByName.invalidate({ name: board.name }); + void utils.board.getHomeBoard.invalidate(); + }, }, - }); - }, [board.id, deleteBoard, router, t, openConfirmModal]); + ); + }; + + const handleDelete = () => { + deleteBoard( + { id: board.id }, + { + onSettled: () => { + router.push("/"); + }, + }, + ); + }; return ( @@ -88,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={t("section.dangerZone.action.rename.label")} description={t("section.dangerZone.action.rename.description")} action={ - + isRenaming ? ( + + + + + + + + ) : ( + + ) } /> {!hideVisibility && ( @@ -98,9 +118,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={t("section.dangerZone.action.visibility.label")} description={t(`section.dangerZone.action.visibility.description.${visibility}`)} action={ - + } /> )} @@ -108,9 +134,15 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: label={tCommonAction("delete")} description={t("section.dangerZone.action.delete.description")} action={ - + } /> diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx index 64f36ea03..fa72af452 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -23,9 +23,9 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; import type { UseFormReturnType } from "@homarr/form"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; import { SectionCard } from "~/components/manage/section-card"; import type { Board } from "../../_types"; @@ -46,7 +46,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync const tCommon = useI18n("common"); const router = useRouter(); const utils = clientApi.useUtils(); - const { openConfirmModal } = useConfirmModal(); const [editingLayoutId, setEditingLayoutId] = useState(null); const [resettingLayoutId, setResettingLayoutId] = useState(null); const { mutateAsync: resetLayout } = clientApi.board.resetLayout.useMutation({ @@ -84,29 +83,20 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync } }; - const confirmReset = (layout: FormValues["layouts"][number]) => { - openConfirmModal({ - title: tBoard("setting.section.layout.reset.confirm.title", { layoutName: layout.name }), - children: tBoard("setting.section.layout.reset.confirm.message"), - confirmProps: { children: tBoard("setting.section.layout.reset.action"), color: "red" }, - onConfirm() { - setResettingLayoutId(layout.id); - void (async () => { - try { - const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); - if (!canonicalLayout) return; + const handleReset = async (layout: FormValues["layouts"][number]) => { + setResettingLayoutId(layout.id); + try { + const canonicalLayout = board.layouts.find((candidate) => candidate.id === layout.id); + if (!canonicalLayout) return; - await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); - await utils.board.getBoardByName.invalidate({ name: board.name }); - router.refresh(); - } catch { - // The mutation callback displays the error notification. - } finally { - setResettingLayoutId(null); - } - })(); - }, - }); + await resetLayout({ boardId: board.id, layoutId: canonicalLayout.id }); + await utils.board.getBoardByName.invalidate({ name: board.name }); + router.refresh(); + } catch { + // The mutation callback displays the error notification. + } finally { + setResettingLayoutId(null); + } }; const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); @@ -236,16 +226,17 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync {layout.role !== "base" && persistedLayout && ( - + )} {layout.role === "custom" && ( + {isMenuVisible && ( + + + + + + + { + setDuplicateFormKey((value) => value + 1); + setIsDuplicateOpen(true); + }} + /> + + )} + + + + + + setIsDuplicateOpen(false)} /> + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx index f6c9ed735..ce9059784 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/create-board-button.tsx @@ -1,23 +1,55 @@ "use client"; -import { Button } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button, Card, Collapse, Group, Stack } from "@mantine/core"; import { IconCategoryPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal } from "@homarr/modals-collection"; +import { BoardCreateForm } from "@homarr/forms-collection"; import { useI18n } from "@homarr/translation/client"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; - export const CreateBoardButton = () => { const t = useI18n("management.page.board.action"); - const { openModal } = useModalAction(AddBoardModal); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isOpen, setIsOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); + + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsOpen(true); + }, [searchParams]); + + const close = () => { + setIsOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/boards", { scroll: false }); + }; + + const toggle = () => { + if (isOpen) { + close(); + return; + } + + setFormKey((value) => value + 1); + setIsOpen(true); + }; return ( - - - + + + + + + + + + + ); }; diff --git a/packages/modals-collection/src/boards/duplicate-board-modal.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx similarity index 62% rename from packages/modals-collection/src/boards/duplicate-board-modal.tsx rename to apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx index 9d9830757..3e0fff888 100644 --- a/packages/modals-collection/src/boards/duplicate-board-modal.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/duplicate-board-form.tsx @@ -1,29 +1,32 @@ +"use client"; + +import { useRouter } from "next/navigation"; import { Button, Group, Stack, Text, TextInput } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; +import { BoardNameAvailability, useBoardNameStatus } from "@homarr/forms-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { boardDuplicateSchema } from "@homarr/validation/board"; -import { createModal } from "../../../modals/src/creator"; -import { useBoardNameStatus } from "./add-board-modal"; - -interface InnerProps { +interface DuplicateBoardFormProps { board: { id: string; name: string; }; + onCancel: () => void; } -export const DuplicateBoardModal = createModal(({ actions, innerProps }) => { +export const DuplicateBoardForm = ({ board, onCancel }: DuplicateBoardFormProps) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); + const router = useRouter(); const form = useZodForm(boardDuplicateSchema.omit({ id: true }), { mode: "controlled", initialValues: { - name: innerProps.board.name, + name: board.name, }, }); const boardNameStatus = useBoardNameStatus(form.values.name); @@ -36,20 +39,21 @@ export const DuplicateBoardModal = createModal(({ actions, innerProp return (
{ - // Prevent submit before name availability check if (!boardNameStatus.canSubmit) return; + await mutateAsync( { ...values, - id: innerProps.board.id, + id: board.id, }, { onSuccess() { - actions.closeModal(); showSuccessNotification({ title: tBoard("action.duplicate.notification.success.title"), message: tBoard("action.duplicate.notification.success.message"), }); + onCancel(); + router.refresh(); }, onError() { showErrorNotification({ @@ -61,39 +65,26 @@ export const DuplicateBoardModal = createModal(({ actions, innerProp ); })} > - + - {tBoard("action.duplicate.message", { name: innerProps.board.name })} + {tBoard("action.duplicate.message", { name: board.name })} - - {boardNameStatus.description.icon ? : null} - {boardNameStatus.description.label} - - ) : null - } + description={} withAsterisk /> - - - - ); -}).withOptions({ - defaultTitle(t) { - return t("board.action.duplicate.title"); - }, -}); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx index c20cf6c74..e3b3a1286 100644 --- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx @@ -1,38 +1,15 @@ -import { - ActionIcon, - Badge, - Button, - Card, - CardSection, - Grid, - GridCol, - Group, - Menu, - MenuTarget, - Text, - Tooltip, -} from "@mantine/core"; -import { - IconDeviceMobile, - IconDotsVertical, - IconHomeFilled, - IconLayoutDashboard, - IconLock, - IconWorld, -} from "@tabler/icons-react"; +import { Grid, GridCol } from "@mantine/core"; +import { IconLayoutDashboard } from "@tabler/icons-react"; -import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; import { constructBoardPermissions } from "@homarr/auth/shared"; import { getI18n } from "@homarr/translation/server"; -import { Link, UserAvatar } from "@homarr/ui"; -import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; import { TourTarget } from "~/components/layout/header/tour-target"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { NoResults } from "~/components/no-results"; -import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; +import { BoardCard } from "./_components/board-card"; import { CreateBoardButton } from "./_components/create-board-button"; export default async function ManageBoardsPage() { @@ -42,24 +19,14 @@ export default async function ManageBoardsPage() { const canCreateBoards = session?.user.permissions.includes("board-create"); return ( - - - - ) : undefined - } - floatingPrimaryAction={canCreateBoards} - > + + {canCreateBoards && ( + + + + )} {boards.length === 0 && ( - + )} {boards.length > 0 && ( @@ -92,97 +59,3 @@ export default async function ManageBoardsPage() { ); } - -interface BoardCardProps { - board: RouterOutputs["board"]["getManageOverview"][number]; - isMenuVisible: boolean; - labels: { - visibility: string; - preview: string; - unknownCreator: string; - home: string; - homeTooltip: string; - mobileHome: string; - mobileHomeTooltip: string; - open: string; - settings: string; - }; -} - -const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => { - const VisibilityIcon = board.isPublic ? IconWorld : IconLock; - - return ( - - - - - - - - - - - - {board.name} - - - - - {board.isHome && ( - - }> - {labels.home} - - - )} - - {board.isMobileHome && ( - - }> - {labels.mobileHome} - - - )} - - {board.creator && ( - - - - )} - - - - - - - - {isMenuVisible && ( - - - - - - - - - )} - - - - ); -}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx index 3db1ab746..985b75d1a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx @@ -10,24 +10,17 @@ import { useI18n } from "@homarr/translation/client"; import { useOptionalHomarrAssistant } from "~/components/assistant/assistant-context"; -interface CopyAiPromptButtonBaseProps { +interface CopyAiPromptButtonProps { rawResponse?: string | null; request?: string | null; documentationUrl?: string | null; + getDraft: () => CustomWidgetAiDraft; + getDiagnostics: () => readonly CustomWidgetAiDiagnostic[]; } -type CopyAiPromptButtonProps = CopyAiPromptButtonBaseProps & - ({ draft: CustomWidgetAiDraft; getDraft?: never } | { draft?: never; getDraft(): CustomWidgetAiDraft }) & - ( - | { diagnostics: readonly CustomWidgetAiDiagnostic[]; getDiagnostics?: never } - | { diagnostics?: never; getDiagnostics(): readonly CustomWidgetAiDiagnostic[] } - ); - export const CopyAiPromptButton = ({ rawResponse, - draft, getDraft, - diagnostics, getDiagnostics, request, documentationUrl, @@ -35,23 +28,14 @@ export const CopyAiPromptButton = ({ const t = useI18n("customWidget"); const assistant = useOptionalHomarrAssistant(); - const readDraft = () => { - if (getDraft) return getDraft(); - return draft; - }; - const readDiagnostics = () => { - if (getDiagnostics) return getDiagnostics(); - return diagnostics; - }; - const handleAssistant = () => { const prompt = buildCustomWidgetAssistantPrompt( undefined, rawResponse, - readDraft(), + getDraft(), request, documentationUrl, - readDiagnostics(), + getDiagnostics(), ); if (!assistant?.enabled || !assistant.sendPrompt(prompt)) return; showSuccessNotification({ title: t("action.aiPrompt"), message: t("notification.aiPromptSent") }); @@ -62,10 +46,10 @@ export const CopyAiPromptButton = ({ const value = buildCustomWidgetAiPrompt( undefined, rawResponse, - readDraft(), + getDraft(), request, documentationUrl, - readDiagnostics(), + getDiagnostics(), ); await navigator.clipboard.writeText(value); showSuccessNotification({ diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 8e376330b..e6ad0594b 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -20,10 +20,9 @@ import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { formatCustomWidgetImportIssues, parseCustomWidgetClipboardDetailed } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; +import { InlineConfirmMenuItem, Link } from "@homarr/ui"; import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog"; import { downloadJson } from "~/components/custom-widgets/download"; @@ -40,7 +39,7 @@ interface WidgetRef { export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { const t = useI18n("customWidget"); - const { openConfirmModal } = useConfirmModal(); + const tCommon = useI18n("common"); const deleteMutation = clientApi.customWidget.delete.useMutation(); const duplicateMutation = clientApi.customWidget.duplicate.useMutation(); const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation(); @@ -173,30 +172,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { }; const handleDelete = () => { - openConfirmModal({ - title: t("action.delete"), - children: t("action.deleteConfirm", { name: widget.name }), - onConfirm: () => { - deleteMutation.mutate( - { id: widget.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("action.delete"), - message: t("notification.deleted", { name: widget.name }), - }); - void utils.customWidget.list.invalidate(); - void utils.customWidget.available.invalidate(); - void utils.widget.customApi.getData.invalidate(); - void revalidatePathActionAsync("/manage/custom-widgets"); - }, - onError: () => { - showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); - }, - }, - ); + deleteMutation.mutate( + { id: widget.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("action.delete"), + message: t("notification.deleted", { name: widget.name }), + }); + void utils.customWidget.list.invalidate(); + void utils.customWidget.available.invalidate(); + void utils.widget.customApi.getData.invalidate(); + void revalidatePathActionAsync("/manage/custom-widgets"); + }, + onError: () => { + showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") }); + }, }, - }); + ); }; return ( @@ -263,14 +256,15 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { )} {!widget.migrationRequired && ( - } - onClick={handleDelete} + onConfirm={handleDelete} + confirmLabel={tCommon("action.confirm")} disabled={deleteMutation.isPending} > {t("action.delete")} - + )} diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx index d357f37b5..77cd96cba 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx @@ -35,7 +35,7 @@ import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer"; import { getWorkshopWebUrl } from "~/components/workshop/workshop-client"; -import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal"; +import { WorkshopReportForm } from "~/components/workshop/workshop-report-form"; import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots"; import { useWorkshopSession } from "~/components/workshop/workshop-session"; import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control"; @@ -213,6 +213,13 @@ export function WorkshopDetail({ id }: { id: string }) { + + @@ -241,6 +248,8 @@ export function WorkshopDetail({ id }: { id: string }) { leftSection={} disabled={!session.user} onClick={reportControls.open} + aria-expanded={reportOpened} + aria-controls="workshop-report-form" > {t("report")} @@ -259,13 +268,6 @@ export function WorkshopDetail({ id }: { id: string }) { - - ); } diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx index c338e06ac..07b3c6da2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/test-connection/test-connection-certificate.tsx @@ -6,11 +6,12 @@ import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; import { createId, getMantineColor } from "@homarr/common"; import { createDocumentationLink } from "@homarr/definitions"; -import { createModal, useConfirmModal, useModalAction } from "@homarr/modals"; -import { AddCertificateModal } from "@homarr/modals-collection"; +import { createModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useCurrentLocale, useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; +import { CertificateUploadForm } from "~/app/[locale]/manage/tools/certificates/_components/certificate-upload-form"; import type { MappedCertificate, MappedTestConnectionCertificateError } from "./types"; interface CertificateErrorDetailsProps { @@ -23,80 +24,71 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; const [showRetryButton, setShowRetryButton] = useState(false); + const [showUploadForm, setShowUploadForm] = useState(false); - const { openModal: openUploadModal } = useModalAction(AddCertificateModal); - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync: trustHostnameAsync } = clientApi.certificates.trustHostnameMismatch.useMutation(); - const { mutateAsync: addCertificateAsync } = clientApi.certificates.addCertificate.useMutation(); + const { mutateAsync: trustHostnameAsync, isPending: isTrustHostnamePending } = + clientApi.certificates.trustHostnameMismatch.useMutation(); + const { mutateAsync: addCertificateAsync, isPending: isTrustSelfSignedPending } = + clientApi.certificates.addCertificate.useMutation(); const rootCertificate = getHeighestCertificate(error.data.certificate); - const handleTrustHostname = () => { + const handleTrustHostname = async () => { const { hostname } = new URL(url); - openConfirmModal({ - title: tError("certificate.hostnameMismatch.confirm.title"), - children: tError("certificate.hostnameMismatch.confirm.message"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await trustHostnameAsync( - { - hostname, - certificate: error.data.certificate.pem, - }, - { - onSuccess() { - showSuccessNotification({ - title: tError("certificate.hostnameMismatch.notification.success.title"), - message: tError("certificate.hostnameMismatch.notification.success.message"), - }); - setShowRetryButton(true); - }, - onError() { - showErrorNotification({ - title: tError("certificate.hostnameMismatch.notification.error.title"), - message: tError("certificate.hostnameMismatch.notification.error.message"), - }); - }, - }, - ); + await trustHostnameAsync( + { + hostname, + certificate: error.data.certificate.pem, }, - }); + { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.hostnameMismatch.notification.success.title"), + message: tError("certificate.hostnameMismatch.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.hostnameMismatch.notification.error.title"), + message: tError("certificate.hostnameMismatch.notification.error.message"), + }); + }, + }, + ); }; - const handleTrustSelfSigned = () => { + const handleTrustSelfSigned = async () => { const { hostname } = new URL(url); - openConfirmModal({ - title: tError("certificate.selfSigned.confirm.title"), - children: tError("certificate.selfSigned.confirm.message"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - const formData = new FormData(); - formData.append( - "file", - new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { - type: "application/x-x509-ca-cert", - }), - ); - await addCertificateAsync(formData, { - onSuccess() { - showSuccessNotification({ - title: tError("certificate.selfSigned.notification.success.title"), - message: tError("certificate.selfSigned.notification.success.message"), - }); - setShowRetryButton(true); - }, - onError() { - showErrorNotification({ - title: tError("certificate.selfSigned.notification.error.title"), - message: tError("certificate.selfSigned.notification.error.message"), - }); - }, + const formData = new FormData(); + formData.append( + "file", + new File([rootCertificate.pem], `${hostname}-${createId()}.crt`, { + type: "application/x-x509-ca-cert", + }), + ); + await addCertificateAsync(formData, { + onSuccess() { + showSuccessNotification({ + title: tError("certificate.selfSigned.notification.success.title"), + message: tError("certificate.selfSigned.notification.success.message"), + }); + setShowRetryButton(true); + }, + onError() { + showErrorNotification({ + title: tError("certificate.selfSigned.notification.error.title"), + message: tError("certificate.selfSigned.notification.error.message"), }); }, }); }; const description = {tError(`certificate.description.${error.data.reason}`)}; + let trustConfirmLabel = tError("certificate.selfSigned.confirm.title"); + if (error.data.reason === "hostnameMismatch") { + trustConfirmLabel = tError("certificate.hostnameMismatch.confirm.title"); + } if (!isAdmin) { return ( @@ -130,30 +122,36 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP {(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) || error.data.reason === "hostnameMismatch" ? ( - + ) : null} {error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? ( - + + {!showUploadForm && ( + + )} + {showUploadForm && ( + + setShowUploadForm(false)} + onSuccess={() => { + setShowRetryButton(true); + setShowUploadForm(false); + }} + /> + + )} + ) : null} ); diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx index d10cd25d2..9f42967a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_integration-buttons.tsx @@ -1,13 +1,12 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface DeleteIntegrationActionButtonProps { integration: { id: string; name: string }; @@ -16,46 +15,42 @@ interface DeleteIntegrationActionButtonProps { export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => { const t = useI18n("integration.page.delete"); const tCommon = useI18n("common"); - const { openConfirmModal } = useConfirmModal(); const utils = clientApi.useUtils(); const { mutateAsync, isPending } = clientApi.integration.delete.useMutation(); + const onConfirm = () => + mutateAsync( + { id: integration.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: tCommon("notification.delete.success"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/integrations"); + void utils.integration.invalidate(); + }, + onError: () => { + showErrorNotification({ + title: tCommon("notification.delete.error"), + message: t("notification.error.message"), + }); + }, + }, + ); + return ( - { - openConfirmModal({ - title: t("title"), - children: t("message", integration), - onConfirm: () => { - void mutateAsync( - { id: integration.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: tCommon("notification.delete.success"), - message: t("notification.success.message"), - }); - void revalidatePathActionAsync("/manage/integrations"); - void utils.integration.invalidate(); - }, - onError: () => { - showErrorNotification({ - title: tCommon("notification.delete.error"), - message: t("notification.error.message"), - }); - }, - }, - ); - }, - }); - }} aria-label={tCommon("action.deleteNamed", { name: integration.name })} > - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx b/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx index 5f1b2f954..fb0579db0 100644 --- a/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx +++ b/apps/nextjs/src/app/[locale]/manage/medias/_actions/delete-media.tsx @@ -1,40 +1,41 @@ "use client"; -import { ActionIcon, Tooltip } from "@mantine/core"; +import { Tooltip } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface DeleteMediaProps { media: RouterOutputs["media"]["getPaginated"]["items"][number]; } export const DeleteMedia = ({ media }: DeleteMediaProps) => { - const { openConfirmModal } = useConfirmModal(); const t = useI18n("media"); + const tCommon = useI18n("common"); const { mutateAsync, isPending } = clientApi.media.deleteMedia.useMutation(); - const onClick = () => { - openConfirmModal({ - title: t("action.delete.label"), - children: t.rich("action.delete.description", { bName: () => {media.name} }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync({ id: media.id }); - await revalidatePathActionAsync("/manage/medias"); - }, - }); + const onConfirm = async () => { + await mutateAsync({ id: media.id }); + await revalidatePathActionAsync("/manage/medias"); }; return ( - + - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx index 15a0c03bd..ab63a55a5 100644 --- a/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/search-engines/_search-engine-delete-button.tsx @@ -1,15 +1,14 @@ "use client"; import { useCallback } from "react"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface SearchEngineDeleteButtonProps { searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number]; @@ -18,48 +17,41 @@ interface SearchEngineDeleteButtonProps { export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => { const t = useI18n("search.engine.page.delete"); const actionT = useI18n("common.action"); - const { openConfirmModal } = useConfirmModal(); const { mutate, isPending } = clientApi.searchEngine.delete.useMutation(); - const onClick = useCallback(() => { - openConfirmModal({ - title: t("title"), - children: t("message", { - name: searchEngine.name, - }), - onConfirm: () => { - mutate( - { id: searchEngine.id }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("notification.success.title"), - message: t("notification.success.message"), - }); - void revalidatePathActionAsync("/manage/search-engines"); - }, - onError: () => { - showErrorNotification({ - title: t("notification.error.title"), - message: t("notification.error.message"), - }); - }, - }, - ); + const onConfirm = useCallback(() => { + mutate( + { id: searchEngine.id }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("notification.success.title"), + message: t("notification.success.message"), + }); + void revalidatePathActionAsync("/manage/search-engines"); + }, + onError: () => { + showErrorNotification({ + title: t("notification.error.title"), + message: t("notification.error.message"), + }); + }, }, - }); - }, [searchEngine, mutate, t, openConfirmModal]); + ); + }, [searchEngine.id, mutate, t]); return ( - - + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx index 53852934d..55073fe92 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-keys.tsx @@ -1,34 +1,67 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core"; +import { useCallback, useMemo, useState } from "react"; +import { Button, Group, Stack, Text, Title } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import type { MRT_ColumnDef, MRT_Row, MRT_TableInstance } from "mantine-react-table"; import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; +import { InlineConfirmActionIcon, UserAvatar } from "@homarr/ui"; -import { CopyApiKeyModal } from "~/app/[locale]/manage/tools/api/components/copy-api-key-modal"; +import { NewApiKeyAlert } from "./new-api-key-alert"; interface ApiKeysManagementProps { apiKeys: RouterOutputs["apiKeys"]["getAll"]; + onCreated: () => void; } -export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { - const { openModal } = useModalAction(CopyApiKeyModal); - const { openConfirmModal } = useConfirmModal(); +type ApiKey = RouterOutputs["apiKeys"]["getAll"][number]; +type ApiKeyCellProps = { row: MRT_Row }; + +interface ApiKeysTableMeta { + handleDelete: (id: string) => Promise; + confirmLabel: string; + deleteTitle: string; + isPendingDelete: boolean; +} + +const ApiKeyCreatedByCell = ({ row }: ApiKeyCellProps) => ( + + + {row.original.user.name} + +); + +const ApiKeyActionsCell = ({ row, table }: ApiKeyCellProps & { table: MRT_TableInstance }) => { + const meta = table.options.meta as ApiKeysTableMeta; + return ( + + meta.handleDelete(row.original.id)} + confirmLabel={meta.confirmLabel} + confirmationAriaLabel={meta.confirmLabel} + loading={meta.isPendingDelete} + color="red" + variant="subtle" + aria-label={meta.deleteTitle} + > + + + + ); +}; + +export const ApiKeysManagement = ({ apiKeys, onCreated }: ApiKeysManagementProps) => { + const [newApiKey, setNewApiKey] = useState(null); const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({ - async onSuccess(data) { - openModal({ - apiKey: data.apiKey, - }); - await revalidatePathActionAsync("/manage/tools/api"); + onSuccess(data) { + setNewApiKey(data.apiKey); + onCreated(); }, }); const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({ @@ -38,21 +71,19 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { }); const t = useI18n("management.page.tool.api.tab.apiKey"); + const tCommon = useI18n("common"); const handleDelete = useCallback( - (id: string) => { - openConfirmModal({ - title: t("modal.delete.title"), - children: t("modal.delete.text"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateDeleteAsync({ apiKeyId: id }); - }, - }); + async (id: string) => { + await mutateDeleteAsync({ apiKeyId: id }); }, - [t, openConfirmModal, mutateDeleteAsync], + [mutateDeleteAsync], ); + const handleDismissNewApiKey = useCallback(async () => { + setNewApiKey(null); + await revalidatePathActionAsync("/manage/tools/api"); + }, []); - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { accessorKey: "id", @@ -61,36 +92,32 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { { accessorKey: "user", header: t("table.header.createdBy"), - Cell: ({ row }) => ( - - - {row.original.user.name} - - ), + Cell: ApiKeyCreatedByCell, }, { header: t("table.header.actions"), - Cell: ({ row }) => ( - - handleDelete(row.original.id)} loading={isPendingDelete} c="red"> - - - - ), + Cell: ApiKeyActionsCell, }, ], - [t, handleDelete, isPendingDelete], + [t], ); const table = useMantineReactTable({ columns, data: apiKeys, + meta: { + handleDelete, + confirmLabel: tCommon("action.confirm"), + deleteTitle: t("modal.delete.title"), + isPendingDelete, + }, renderTopToolbarCustomActions: () => ( @@ -104,6 +131,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => { return ( {t("title")} + {newApiKey && } ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx index c56dfc916..442f5446f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/api-page-tabs.tsx @@ -5,6 +5,8 @@ import type { ReactNode } from "react"; import { Tabs, TabsList, TabsPanel, TabsTab } from "@mantine/core"; import { IconBrain, IconCode, IconKey } from "@tabler/icons-react"; +import type { RouterOutputs } from "@homarr/api"; +import { ApiKeysManagement } from "./api-keys"; import { McpInstructions } from "./mcp-instructions"; export interface McpToolGroup { @@ -17,9 +19,8 @@ interface ApiPageTabsProps { apiKeyLabel: string; mcpLabel: string; documentationPanel: ReactNode; - authenticationPanel: ReactNode; + apiKeys: RouterOutputs["apiKeys"]["getAll"]; baseUrl: string; - hasApiKeys: boolean; toolGroups: McpToolGroup[]; } @@ -28,12 +29,12 @@ export function ApiPageTabs({ apiKeyLabel, mcpLabel, documentationPanel, - authenticationPanel, + apiKeys, baseUrl, - hasApiKeys, toolGroups, }: ApiPageTabsProps) { const [activeTab, setActiveTab] = useState("documentation"); + const [hasApiKeys, setHasApiKeys] = useState(apiKeys.length > 0); return ( @@ -48,10 +49,17 @@ export function ApiPageTabs({ {mcpLabel} - {authenticationPanel} + + setHasApiKeys(true)} /> + {documentationPanel} - + setHasApiKeys(true)} + /> ); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx deleted file mode 100644 index dbaee24ef..000000000 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/copy-api-key-modal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; - -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -export const CopyApiKeyModal = createModal<{ apiKey: string }>(({ actions, innerProps }) => { - const t = useI18n("management.page.tool.api.modal.createApiToken"); - const [visible, { toggle }] = useDisclosure(false); - return ( - - {t("description")} - - - {({ copy }) => ( - - )} - - - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.tool.api.modal.createApiToken.title"); - }, -}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx index 2a5251a4d..d3fd2e112 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useState } from "react"; import { Accordion, AccordionControl, @@ -36,12 +37,11 @@ import { import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { CopyApiKeyModal } from "./copy-api-key-modal"; import type { McpToolGroup } from "./api-page-tabs"; import classes from "./mcp-tools-accordion.module.css"; +import { NewApiKeyAlert } from "./new-api-key-alert"; const toolTypeDisplay: Record<"query" | "mutation", { color: string; method: string }> = { query: { color: "blue", method: "GET" }, @@ -52,18 +52,23 @@ interface McpInstructionsProps { baseUrl: string; hasApiKeys: boolean; toolGroups: McpToolGroup[]; + onApiKeyCreated: () => void; } -export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstructionsProps) { +export function McpInstructions({ baseUrl, hasApiKeys, toolGroups, onApiKeyCreated }: McpInstructionsProps) { const t = useI18n("management.page.tool.api.tab.mcp"); const tCommon = useI18n("common"); - const { openModal } = useModalAction(CopyApiKeyModal); + const [newApiKey, setNewApiKey] = useState(null); const { mutate: createApiKey, isPending } = clientApi.apiKeys.create.useMutation({ - async onSuccess(data) { - openModal({ apiKey: data.apiKey }); - await revalidatePathActionAsync("/manage/tools/api"); + onSuccess(data) { + setNewApiKey(data.apiKey); + onApiKeyCreated(); }, }); + const handleDismissNewApiKey = useCallback(async () => { + setNewApiKey(null); + await revalidatePathActionAsync("/manage/tools/api"); + }, []); const mcpUrl = `${baseUrl}/api/mcp`; const streamableHttpConfig = JSON.stringify( @@ -114,7 +119,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct {t("description")} - {!hasApiKeys && ( + {!hasApiKeys && !newApiKey && ( }> {t("noApiKey")} @@ -131,6 +136,8 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct )} + {newApiKey && } +
<Group gap="xs"> @@ -305,7 +312,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct <Stack gap={6}> {group.tools.map((tool) => ( <div key={tool.name} className={classes.toolRow}> - <Badge size="sm" w={50} radius="xs" variant="light" color={toolTypeDisplay[tool.type].color}> + <Badge size="sm" w={50} radius="xs" color={toolTypeDisplay[tool.type].color} variant="light"> {toolTypeDisplay[tool.type].method} </Badge> <div> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx new file mode 100644 index 000000000..2b2f03182 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/new-api-key-alert.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Alert, Button, CopyButton, PasswordInput, Stack, Text } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { IconCheck, IconCopy } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface NewApiKeyAlertProps { + apiKey: string; + onDismiss: () => void | Promise<void>; +} + +export const NewApiKeyAlert = ({ apiKey, onDismiss }: NewApiKeyAlertProps) => { + const t = useI18n("management.page.tool.api.modal.createApiToken"); + const tCommon = useI18n("common"); + const [visible, { toggle }] = useDisclosure(false); + + return ( + <Alert color="yellow" title={t("title")}> + <Stack gap="sm"> + <Text size="sm">{t("description")}</Text> + <PasswordInput value={apiKey} visible={visible} onVisibilityChange={toggle} aria-label={t("title")} readOnly /> + <Stack gap="xs" align="stretch"> + <CopyButton value={apiKey} timeout={2_000}> + {({ copied, copy }) => { + let color: "green" | undefined; + let icon = <IconCopy size={16} />; + let label = tCommon("action.copy"); + if (copied) { + color = "green"; + icon = <IconCheck size={16} />; + label = tCommon("action.copied"); + } + + return ( + <Button onClick={copy} variant="default" color={color} leftSection={icon}> + {label} + </Button> + ); + }} + </CopyButton> + <Button onClick={onDismiss} variant="subtle"> + {tCommon("action.close")} + </Button> + </Stack> + </Stack> + </Alert> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index 48b1f47a0..215dee98f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -10,7 +10,6 @@ import { getI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; -import { ApiKeysManagement } from "./components/api-keys"; import { ApiPageTabs } from "./components/api-page-tabs"; import { ScalarApiReference } from "./components/scalar-api-reference"; @@ -72,9 +71,8 @@ export default async function ApiPage() { apiKeyLabel={t("apiKey.label")} mcpLabel={t("mcp.label")} documentationPanel={<ScalarApiReference document={document} />} - authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />} + apiKeys={apiKeys} baseUrl={baseUrl} - hasApiKeys={apiKeys.length > 0} toolGroups={toolGroups} /> </Stack> diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx index 206dcb0a2..21731871a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/add-certificate.tsx @@ -1,23 +1,35 @@ "use client"; -import { Button } from "@mantine/core"; +import { Button, Card, Collapse, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useModalAction } from "@homarr/modals"; -import { AddCertificateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -export const AddCertificateButton = () => { - const { openModal } = useModalAction(AddCertificateModal); +import { CertificateUploadForm } from "./certificate-upload-form"; + +export const AddCertificateSection = () => { + const [opened, { close, toggle }] = useDisclosure(false); const t = useI18n("certificate"); - const handleClick = () => { - openModal({ - async onSuccess() { - await revalidatePathActionAsync("/manage/tools/certificates"); - }, - }); - }; - - return <Button onClick={handleClick}>{t("action.create.label")}</Button>; + return ( + <Stack gap="sm"> + <Button onClick={toggle} variant="default" aria-expanded={opened}> + {t("action.create.label")} + </Button> + <Collapse expanded={opened}> + <Card withBorder> + <CertificateUploadForm + onCancel={close} + onSuccess={async () => { + await revalidatePathActionAsync("/manage/tools/certificates"); + close(); + }} + /> + </Card> + </Collapse> + </Stack> + ); }; + +export { CertificateUploadForm }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx new file mode 100644 index 000000000..512caf068 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/certificate-upload-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Button, FileInput, Group, Stack } from "@mantine/core"; +import { IconCertificate } from "@tabler/icons-react"; +import { z } from "zod/v4"; + +import { clientApi } from "@homarr/api/client"; +import type { MaybePromise } from "@homarr/common/types"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { checkCertificateFile } from "@homarr/validation/certificates"; + +interface CertificateUploadFormProps { + embedded?: boolean; + onSuccess?: () => MaybePromise<void>; + onCancel?: () => void; +} + +export const CertificateUploadForm = ({ embedded = false, onSuccess, onCancel }: CertificateUploadFormProps) => { + const t = useI18n(); + const form = useZodForm( + z.object({ + file: z.file().check(checkCertificateFile), + }), + { + initialValues: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + file: null!, + }, + }, + ); + const { mutateAsync, isPending } = clientApi.certificates.addCertificate.useMutation(); + + const handleSubmit = async () => { + const validation = form.validate(); + if (validation.hasErrors || !form.values.file) return; + + const formData = new FormData(); + formData.set("file", form.values.file); + await mutateAsync(formData, { + onSuccess: async () => { + showSuccessNotification({ + title: t("certificate.action.create.notification.success.title"), + message: t("certificate.action.create.notification.success.message"), + }); + form.reset(); + await onSuccess?.(); + }, + onError: () => { + showErrorNotification({ + title: t("certificate.action.create.notification.error.title"), + message: t("certificate.action.create.notification.error.message"), + }); + }, + }); + }; + + const handleCancel = () => { + form.reset(); + onCancel?.(); + }; + + const submit = () => { + void handleSubmit().catch(() => undefined); + }; + + let submitButtonType: "button" | "submit" = "submit"; + let handleSubmitClick: (() => void) | undefined; + if (embedded) { + submitButtonType = "button"; + handleSubmitClick = submit; + } + + const content = ( + <> + <FileInput + label={t("certificate.action.create.label")} + leftSection={<IconCertificate size={16} />} + {...form.getInputProps("file")} + /> + <Group justify="end"> + {onCancel && ( + <Button onClick={handleCancel} variant="subtle" color="gray"> + {t("common.action.cancel")} + </Button> + )} + <Button type={submitButtonType} onClick={handleSubmitClick} loading={isPending}> + {t("common.action.add")} + </Button> + </Group> + </> + ); + + if (embedded) { + return <Stack>{content}</Stack>; + } + + return ( + <Stack + component="form" + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + {content} + </Stack> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx index 0f252cf84..274bdf816 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/_components/remove-certificate.tsx @@ -1,57 +1,56 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface RemoveCertificateProps { fileName: string; } export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => { - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation({ + const { mutateAsync, isPending } = clientApi.certificates.removeCertificate.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/tools/certificates"); }, }); const t = useI18n("certificate"); + const tCommon = useI18n("common"); - const handleClick = () => { - openConfirmModal({ - title: t("action.remove.label"), - children: t("action.remove.confirm"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync( - { fileName }, - { - onSuccess() { - showSuccessNotification({ - title: t("action.remove.notification.success.title"), - message: t("action.remove.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: t("action.remove.notification.error.title"), - message: t("action.remove.notification.error.message"), - }); - }, - }, - ); + const handleRemove = () => + mutateAsync( + { fileName }, + { + onSuccess() { + showSuccessNotification({ + title: t("action.remove.notification.success.title"), + message: t("action.remove.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("action.remove.notification.error.title"), + message: t("action.remove.notification.error.message"), + }); + }, }, - }); - }; + ); return ( - <ActionIcon onClick={handleClick} color="red" variant="subtle"> + <InlineConfirmActionIcon + onConfirm={handleRemove} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + color="red" + variant="subtle" + aria-label={t("action.remove.label")} + > <IconTrash color="red" size={16} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx index beacd0913..10c52aff2 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/hostnames/_components/remove-hostname.tsx @@ -1,13 +1,12 @@ "use client"; -import { ActionIcon } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; interface RemoveHostnameActionIconProps { hostname: string; @@ -15,41 +14,41 @@ interface RemoveHostnameActionIconProps { } export const RemoveHostnameActionIcon = (input: RemoveHostnameActionIconProps) => { - const { mutateAsync } = clientApi.certificates.removeTrustedHostname.useMutation({ + const { mutateAsync, isPending } = clientApi.certificates.removeTrustedHostname.useMutation({ async onSuccess() { await revalidatePathActionAsync("/manage/tools/certificates/hostnames"); }, }); - const { openConfirmModal } = useConfirmModal(); const t = useI18n("certificate"); + const tCommon = useI18n("common"); - const handleRemove = () => { - openConfirmModal({ - title: t("action.removeHostname.label"), - children: t("action.removeHostname.confirm"), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync(input, { - onSuccess() { - showSuccessNotification({ - title: t("action.removeHostname.notification.success.title"), - message: t("action.removeHostname.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: t("action.removeHostname.notification.error.title"), - message: t("action.removeHostname.notification.error.message"), - }); - }, + const handleRemove = () => + mutateAsync(input, { + onSuccess() { + showSuccessNotification({ + title: t("action.removeHostname.notification.success.title"), + message: t("action.removeHostname.notification.success.message"), + }); + }, + onError() { + showErrorNotification({ + title: t("action.removeHostname.notification.error.title"), + message: t("action.removeHostname.notification.error.message"), }); }, }); - }; return ( - <ActionIcon color="red" variant="subtle" onClick={handleRemove}> + <InlineConfirmActionIcon + onConfirm={handleRemove} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + color="red" + variant="subtle" + aria-label={t("action.removeHostname.label")} + > <IconTrash color="red" size={16} stroke={1.5} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx index 2f75a8be4..9be9bdb42 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/certificates/page.tsx @@ -13,7 +13,7 @@ import { Link } from "@homarr/ui"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NoResults } from "~/components/no-results"; -import { AddCertificateButton } from "./_components/add-certificate"; +import { AddCertificateSection } from "./_components/add-certificate"; import { RemoveCertificate } from "./_components/remove-certificate"; interface CertificatesPageProps { @@ -69,10 +69,11 @@ export default async function CertificatesPage({ params }: CertificatesPageProps <Button variant="default" component={Link} href="/manage/tools/certificates/hostnames"> {t("page.list.toHostnames")} </Button> - <AddCertificateButton /> </Group> </Group> + <AddCertificateSection /> + {x509Certificates.length === 0 && ( <NoResults icon={IconCertificateOff} title={t("page.list.noResults.title")} /> )} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx index 813b191cc..adefc229a 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface ConfigMapsTableComponentProps { @@ -47,13 +49,7 @@ export function ConfigmapsTable({ contextId, initialConfigMaps }: ConfigMapsTabl const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery( { contextId }, - { - initialData: initialConfigMaps, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialConfigMaps), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx index dcc2a2e1d..372dd5da9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import KubernetesErrorPage from "../cluster-dashboard/error"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; dayjs.extend(relativeTime); @@ -84,13 +85,7 @@ export function IngressesTable({ contextId, initialIngresses }: IngressesTableCo const { data, isError } = clientApi.kubernetes.ingresses.getIngresses.useQuery( { contextId }, - { - initialData: initialIngresses, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialIngresses), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts new file mode 100644 index 000000000..217a66a70 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/kubernetes-query-options.ts @@ -0,0 +1,18 @@ +interface QueryWithStatus { + state: { + status: string; + }; +} + +const getErrorRefetchInterval = (query: QueryWithStatus) => { + if (query.state.status === "error") return 30_000; + return false; +}; + +export const createKubernetesResourceQueryOptions = <TData>(initialData: TData) => ({ + initialData, + refetchOnMount: "always" as const, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: getErrorRefetchInterval, +}); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx index d70da8bc4..0c1a01c9d 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx @@ -15,6 +15,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface NamespacesTableComponentProps { @@ -66,13 +68,7 @@ export function NamespacesTable({ contextId, initialNamespaces }: NamespacesTabl const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery( { contextId }, - { - initialData: initialNamespaces, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialNamespaces), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx index 66e64059c..112ee25f3 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx @@ -17,6 +17,7 @@ import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import KubernetesErrorPage from "../cluster-dashboard/error"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; dayjs.extend(relativeTime); @@ -97,13 +98,7 @@ export function NodesTable({ contextId, initialNodes }: NodesListComponentProps) const { data, isError } = clientApi.kubernetes.nodes.getNodes.useQuery( { contextId }, - { - initialData: initialNodes, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialNodes), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx index 855090602..83395f2b7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface PodsTableComponentProps { @@ -57,13 +59,7 @@ export function PodsTable({ contextId, initialPods }: PodsTableComponentProps) { const { data } = clientApi.kubernetes.pods.getPods.useQuery( { contextId }, - { - initialData: initialPods, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialPods), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx index 2c31987a7..4e3cab8c7 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface SecretsTableComponentProps { @@ -52,13 +54,7 @@ export function SecretsTable({ contextId, initialSecrets }: SecretsTableComponen const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery( { contextId }, - { - initialData: initialSecrets, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialSecrets), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx index aa2c87c77..3a206cda9 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx @@ -14,6 +14,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface ServicesTableComponentProps { @@ -71,13 +73,7 @@ export function ServicesTable({ contextId, initialServices }: ServicesTableCompo const { data } = clientApi.kubernetes.services.getServices.useQuery( { contextId }, - { - initialData: initialServices, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialServices), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx index 4b54dbe34..e9da29962 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx @@ -13,6 +13,8 @@ import type { ScopedTranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { createKubernetesResourceQueryOptions } from "../kubernetes-query-options"; + dayjs.extend(relativeTime); interface VolumesTableComponentProps { @@ -76,13 +78,7 @@ export function VolumesTable({ contextId, initialVolumes }: VolumesTableComponen const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery( { contextId }, - { - initialData: initialVolumes, - refetchOnMount: "always", - refetchOnWindowFocus: true, - refetchOnReconnect: true, - refetchInterval: (query) => (query.state.status === "error" ? 30_000 : false), - }, + createKubernetesResourceQueryOptions(initialVolumes), ); const table = useTranslatedMantineReactTable({ diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx index c26e7cf06..2b0417611 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_delete-user-button.tsx @@ -2,20 +2,18 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface DeleteUserButtonProps { user: RouterOutputs["user"]["getById"]; } export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { - const tUser = useI18n("user"); const tCommon = useI18n("common"); const router = useRouter(); const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({ @@ -23,27 +21,14 @@ export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => { await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users")); }, }); - const { openConfirmModal } = useConfirmModal(); - const handleDelete = useCallback( - () => - openConfirmModal({ - title: tUser("action.delete.label"), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - children: tUser("action.delete.confirm", { username: user.name! }), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateUserDeletionAsync({ - userId: user.id, - }); - }, - }), - [user, mutateUserDeletionAsync, openConfirmModal, tUser], + () => mutateUserDeletionAsync({ userId: user.id }), + [mutateUserDeletionAsync, user.id], ); return ( - <Button onClick={handleDelete} variant="subtle" color="red"> + <InlineConfirmButton onConfirm={handleDelete} confirmLabel={tCommon("action.confirm")} variant="subtle" color="red"> {tCommon("action.delete")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx index 128d3c4cc..ec45ed3e2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/general/_components/_profile-avatar-form.tsx @@ -2,31 +2,30 @@ import { useCallback } from "react"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; -import { useDisclosure, useMediaQuery } from "@mantine/hooks"; +import { useDisclosure } from "@mantine/hooks"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; +import { InlineConfirmMenuItem, UserAvatar } from "@homarr/ui"; +import { useIsMobile } from "@homarr/ui/hooks"; interface UserProfileAvatarForm { user: RouterOutputs["user"]["getById"]; } export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { - const { mutate } = clientApi.user.setProfileImage.useMutation({ + const { mutate, mutateAsync, isPending } = clientApi.user.setProfileImage.useMutation({ async onSuccess() { // Revalidate all as the avatar is used in multiple places await revalidatePathActionAsync("/"); }, }); const [opened, { toggle }] = useDisclosure(false); - const isMobile = useMediaQuery("(max-width: 48em)"); - const { openConfirmModal } = useConfirmModal(); + const isMobile = useIsMobile(); const tCommon = useI18n("common"); const tManageAvatar = useI18n("user.action.manageAvatar"); @@ -67,32 +66,28 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { [mutate, user.id, tManageAvatar], ); - const handleRemoveAvatar = useCallback(() => { - openConfirmModal({ - title: tManageAvatar("removeImage.label"), - children: tManageAvatar("removeImage.confirm"), - onConfirm() { - mutate( - { - userId: user.id, - image: null, + const handleRemoveAvatar = useCallback( + () => + mutateAsync( + { + userId: user.id, + image: null, + }, + { + onSuccess() { + showSuccessNotification({ + message: tManageAvatar("removeImage.notification.success.message"), + }); }, - { - onSuccess() { - showSuccessNotification({ - message: tManageAvatar("removeImage.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - message: tManageAvatar("removeImage.notification.error.message"), - }); - }, + onError() { + showErrorNotification({ + message: tManageAvatar("removeImage.notification.error.message"), + }); }, - ); - }, - }); - }, [mutate, user.id, openConfirmModal, tManageAvatar]); + }, + ), + [mutateAsync, user.id, tManageAvatar], + ); return ( <Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}> @@ -123,9 +118,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => { )} </FileButton> {user.image && ( - <Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}> + <InlineConfirmMenuItem + color="red" + onConfirm={handleRemoveAvatar} + confirmLabel={tCommon("action.confirm")} + disabled={isPending} + leftSection={<IconPhotoX size={16} stroke={1.5} />} + > {tManageAvatar("removeImage.label")} - </Menu.Item> + </InlineConfirmMenuItem> )} </Menu.Dropdown> </Menu> diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index de48d9ba1..2b01ca6a6 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -12,7 +12,6 @@ import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import type { IsValid } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { UserCreatePasswordFields } from "@homarr/forms-collection"; -import { useModalAction } from "@homarr/modals"; import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { UserAvatar } from "@homarr/ui"; @@ -20,7 +19,7 @@ import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { optionalEmailSchema } from "@homarr/validation/email"; import { userPasswordSchema } from "@homarr/validation/user"; -import { GroupSelectModal } from "~/components/access/group-select-modal"; +import { GroupSelect } from "~/components/access/group-select"; import { StepperNavigationComponent } from "./stepper-navigation"; interface GroupWithPermissions { @@ -224,23 +223,6 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) = const tCommon = useI18n("common"); const tPermission = useI18n("permission"); const [groups, { append, filter }] = useListState<GroupWithPermissions>(initialGroups); - const { openModal } = useModalAction(GroupSelectModal); - - const handleAddClick = () => { - openModal({ - presentGroupIds: groups.map((group) => group.id), - withPermissions: true, - onSelect({ id, name, permissions }) { - if (!permissions) return; - - startTransition(() => { - addGroup(id); - append({ id, name, permissions }); - }); - }, - }); - }; - const handleGroupRemove = (id: string) => { filter((group) => group.id !== id); removeGroup(id); @@ -256,14 +238,23 @@ const GroupsForm = ({ addGroup, removeGroup, initialGroups }: GroupsFormProps) = {tCreate("step.groups.description", { everyoneGroup })} </Text> </Stack> - <Button - variant="subtle" - color="gray" - leftSection={<IconPlus size={16} stroke={1.5} />} - onClick={handleAddClick} - > - {tCommon("action.add")} - </Button> + <GroupSelect + presentGroupIds={groups.map((group) => group.id)} + withPermissions + onSelect={({ id, name, permissions }) => { + if (!permissions) return; + + startTransition(() => { + addGroup(id); + append({ id, name, permissions }); + }); + }} + triggerProps={{ + variant: "subtle", + color: "gray", + leftSection: <IconPlus size={16} stroke={1.5} />, + }} + /> </Group> <Table> <Table.Thead> diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx index 7db623644..5946062d8 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_delete-group.tsx @@ -2,13 +2,13 @@ import { useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Button, useMatches } from "@mantine/core"; +import { useMatches } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface DeleteGroupProps { group: { @@ -20,46 +20,38 @@ interface DeleteGroupProps { export const DeleteGroup = ({ group }: DeleteGroupProps) => { const router = useRouter(); const { mutateAsync } = clientApi.group.deleteGroup.useMutation(); - const { openConfirmModal } = useConfirmModal(); const tDelete = useI18n("group.action.delete"); const tCommon = useI18n("common"); - const handleDeletion = useCallback(() => { - openConfirmModal({ - title: tDelete("label"), - children: tDelete("confirm", { - name: group.name, - }), - // eslint-disable-next-line no-restricted-syntax - async onConfirm() { - await mutateAsync( - { - id: group.id, + const handleDeletion = useCallback( + () => + mutateAsync( + { + id: group.id, + }, + { + onSuccess() { + void revalidatePathActionAsync("/manage/users/groups"); + router.push("/manage/users/groups"); + showSuccessNotification({ + title: tCommon("notification.delete.success"), + message: tDelete("notification.success.message", { + name: group.name, + }), + }); }, - { - onSuccess() { - void revalidatePathActionAsync("/manage/users/groups"); - router.push("/manage/users/groups"); - showSuccessNotification({ - title: tCommon("notification.delete.success"), - message: tDelete("notification.success.message", { - name: group.name, - }), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.delete.error"), - message: tDelete("notification.error.message", { - name: group.name, - }), - }); - }, + onError() { + showErrorNotification({ + title: tCommon("notification.delete.error"), + message: tDelete("notification.error.message", { + name: group.name, + }), + }); }, - ); - }, - }); - }, [tDelete, tCommon, openConfirmModal, group.id, group.name, mutateAsync, router]); + }, + ), + [tDelete, tCommon, group.id, group.name, mutateAsync, router], + ); const fullWidth = useMatches({ xs: true, @@ -68,8 +60,14 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => { }); return ( - <Button variant="subtle" color="red" onClick={handleDeletion} fullWidth={fullWidth}> + <InlineConfirmButton + variant="subtle" + color="red" + onConfirm={handleDeletion} + confirmLabel={tCommon("action.confirm")} + fullWidth={fullWidth} + > {tDelete("label")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx index 1d0b26b8c..46afda084 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/_transfer-group-ownership.tsx @@ -1,15 +1,14 @@ "use client"; -import { useCallback, useState } from "react"; -import { Button, useMatches } from "@mantine/core"; +import { useState } from "react"; +import { Button, Group, Stack, Text, useMatches } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/components/access/user-select-modal"; +import { UserSelect } from "~/components/access/user-select"; interface TransferGroupOwnershipProps { group: { @@ -19,75 +18,87 @@ interface TransferGroupOwnershipProps { }; } +interface SelectedOwner { + id: string; + name: string; +} + export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => { const tTransfer = useI18n("group.action.transfer"); const tCommon = useI18n("common"); const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId); - const { openModal } = useModalAction(UserSelectModal); - const { openConfirmModal } = useConfirmModal(); - const { mutateAsync } = clientApi.group.transferOwnership.useMutation({ + const [selectedOwner, setSelectedOwner] = useState<SelectedOwner>(); + const { mutateAsync, isPending } = clientApi.group.transferOwnership.useMutation({ async onSuccess() { await revalidatePathActionAsync(`/manage/users/groups/${group.id}`); }, }); - const handleTransfer = useCallback(() => { - openModal( + const handleTransfer = async () => { + if (!selectedOwner) return; + + await mutateAsync( { - confirmLabel: tCommon("action.continue"), - presentUserIds: innerOwnerId ? [innerOwnerId] : [], - onSelect: ({ id, name }) => { - openConfirmModal({ - title: tTransfer("label"), - children: tTransfer("confirm", { - name: group.name, - username: name, + groupId: group.id, + userId: selectedOwner.id, + }, + { + onSuccess() { + setInnerOwnerId(selectedOwner.id); + showSuccessNotification({ + title: tCommon("notification.transfer.success"), + message: tTransfer("notification.success.message", { + group: group.name, + user: selectedOwner.name, }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync( - { - groupId: group.id, - userId: id, - }, - { - onSuccess() { - setInnerOwnerId(id); - showSuccessNotification({ - title: tCommon("notification.transfer.success"), - message: tTransfer("notification.success.message", { - group: group.name, - user: name, - }), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.transfer.error"), - message: tTransfer("notification.error.message"), - }); - }, - }, - ); - }, + }); + setSelectedOwner(undefined); + }, + onError() { + showErrorNotification({ + title: tCommon("notification.transfer.error"), + message: tTransfer("notification.error.message"), }); }, }, - { - title: tTransfer("label"), - }, ); - }, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tCommon, tTransfer]); + }; const fullWidth = useMatches({ xs: true, sm: true, md: false, }); + const presentUserIds: string[] = []; + if (innerOwnerId) presentUserIds.push(innerOwnerId); + + if (!selectedOwner) { + return ( + <UserSelect + presentUserIds={presentUserIds} + onSelect={setSelectedOwner} + triggerLabel={tTransfer("label")} + triggerProps={{ variant: "subtle", color: "red", fullWidth }} + /> + ); + } return ( - <Button variant="subtle" color="red" onClick={handleTransfer} fullWidth={fullWidth}> - {tTransfer("label")} - </Button> + <Stack gap="xs" maw={420}> + <Text size="sm"> + {tTransfer("confirm", { + name: group.name, + username: selectedOwner.name, + })} + </Text> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={() => setSelectedOwner(undefined)} disabled={isPending}> + {tCommon("action.cancel")} + </Button> + <Button color="red" onClick={() => void handleTransfer()} loading={isPending}> + {tCommon("action.confirm")} + </Button> + </Group> + </Stack> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index 6585e3205..20d2aa381 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -5,11 +5,9 @@ import { useCallback } from "react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import type { SupportedAuthProvider } from "@homarr/definitions"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import { UserSelectModal } from "~/components/access/user-select-modal"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { UserSelect } from "~/components/access/user-select"; interface AddGroupMemberProps { groupId: string; @@ -20,27 +18,23 @@ interface AddGroupMemberProps { export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => { const tMembersAdd = useI18n("group.action.addMember"); const { mutateAsync } = clientApi.group.addMember.useMutation(); - const { openModal } = useModalAction(UserSelectModal); + const handleAddMember = useCallback( + async ({ id }: { id: string }) => { + await mutateAsync({ + userId: id, + groupId, + }); + await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); + }, + [groupId, mutateAsync], + ); - const handleAddMember = useCallback(() => { - openModal( - { - // eslint-disable-next-line no-restricted-syntax - async onSelect({ id }) { - await mutateAsync({ - userId: id, - groupId, - }); - await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`); - }, - presentUserIds, - allowedProviders, - }, - { - title: tMembersAdd("label"), - }, - ); - }, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]); - - return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>; + return ( + <UserSelect + presentUserIds={presentUserIds} + allowedProviders={allowedProviders} + onSelect={handleAddMember} + triggerLabel={tMembersAdd("label")} + /> + ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx index c290bd26a..600df6e81 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_remove-group-member.tsx @@ -1,12 +1,11 @@ "use client"; import { useCallback } from "react"; -import { Button } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmButton } from "@homarr/ui"; interface RemoveGroupMemberProps { groupId: string; @@ -15,30 +14,25 @@ interface RemoveGroupMemberProps { export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => { const tCommon = useI18n("common"); - const tRemoveMember = useI18n("group.action.removeMember"); const { mutateAsync } = clientApi.group.removeMember.useMutation(); - const { openConfirmModal } = useConfirmModal(); - const handleRemove = useCallback(() => { - openConfirmModal({ - title: tRemoveMember("label"), - children: tRemoveMember("confirm", { - user: user.name ?? "", - }), - // eslint-disable-next-line no-restricted-syntax - onConfirm: async () => { - await mutateAsync({ - groupId, - userId: user.id, - }); - await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); - }, + const handleRemove = useCallback(async () => { + await mutateAsync({ + groupId, + userId: user.id, }); - }, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]); + await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`); + }, [mutateAsync, groupId, user.id]); return ( - <Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}> + <InlineConfirmButton + variant="subtle" + color="red.9" + size="compact-sm" + confirmLabel={tCommon("action.confirm")} + onConfirm={handleRemove} + > {tCommon("action.remove")} - </Button> + </InlineConfirmButton> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx index 57d544ef3..2386d070f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_client.tsx @@ -1,15 +1,14 @@ "use client"; -import { useMemo, useState } from "react"; -import { TextInput } from "@mantine/core"; +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button, Card, Collapse, Group, Stack, TextInput } from "@mantine/core"; import { IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; -import { useModalAction } from "@homarr/modals"; -import { AddGroupModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; -import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; +import { GroupCreateForm } from "./_components/group-create-form"; import { GroupsTable } from "./_groups-table"; interface GroupsListProps { @@ -18,6 +17,10 @@ interface GroupsListProps { export const GroupsList = ({ groups }: GroupsListProps) => { const [search, setSearch] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); const initialGroupIds = useMemo( () => groups.sort((groupA, groupB) => groupA.position - groupB.position).map((group) => group.id), [groups], @@ -31,8 +34,46 @@ export const GroupsList = ({ groups }: GroupsListProps) => { ); const tGroup = useI18n("group"); + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }, [searchParams]); + + const closeCreate = () => { + setIsCreateOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/users/groups", { scroll: false }); + }; + + const toggleCreate = () => { + if (isCreateOpen) { + closeCreate(); + return; + } + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }; + return ( - <> + <Stack> + <Group justify="end"> + <Button onClick={toggleCreate}>{tGroup("action.create.label")}</Button> + </Group> + <Collapse expanded={isCreateOpen}> + <Card withBorder> + <GroupCreateForm + key={formKey} + onCancel={closeCreate} + onCreated={() => { + closeCreate(); + router.refresh(); + }} + /> + </Card> + </Collapse> <TextInput leftSection={<IconSearch size={20} stroke={1.5} />} value={search} @@ -40,13 +81,6 @@ export const GroupsList = ({ groups }: GroupsListProps) => { placeholder={`${tGroup("search")}...`} /> <GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} /> - </> + </Stack> ); }; - -export const AddGroupButton = () => { - const tGroup = useI18n("group"); - const { openModal } = useModalAction(AddGroupModal); - - return <MobileAffixButton onClick={() => openModal()}>{tGroup("action.create.label")}</MobileAffixButton>; -}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx new file mode 100644 index 000000000..35f2457cc --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/_components/group-create-form.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Button, Group, Stack, TextInput } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { revalidatePathActionAsync } from "@homarr/common/client"; +import { useZodForm } from "@homarr/form"; +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { groupCreateSchema } from "@homarr/validation/group"; + +interface GroupCreateFormProps { + onCancel: () => void; + onCreated: () => void; +} + +export const GroupCreateForm = ({ onCancel, onCreated }: GroupCreateFormProps) => { + const tGroup = useI18n("group"); + const tCommon = useI18n("common"); + const form = useZodForm(groupCreateSchema, { + initialValues: { + name: "", + }, + }); + const { mutateAsync, isPending } = clientApi.group.createGroup.useMutation(); + + const handleSubmit = form.onSubmit(async (values) => { + await mutateAsync(values, { + async onSuccess() { + await revalidatePathActionAsync("/manage/users/groups"); + showSuccessNotification({ + title: tCommon("notification.create.success"), + message: tGroup("action.create.notification.success.message"), + }); + onCreated(); + }, + onError() { + showErrorNotification({ + title: tCommon("notification.create.error"), + message: tGroup("action.create.notification.error.message"), + }); + }, + }); + }); + + return ( + <form onSubmit={handleSubmit}> + <Stack> + <TextInput label={tCommon("field.name")} autoFocus {...form.getInputProps("name")} /> + <Group justify="end"> + <Button variant="default" onClick={onCancel}> + {tCommon("action.cancel")} + </Button> + <Button loading={isPending} type="submit"> + {tCommon("action.create")} + </Button> + </Group> + </Stack> + </form> + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx index ba9df38e3..eced4a16d 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx @@ -9,7 +9,7 @@ import { getI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; -import { AddGroupButton, GroupsList } from "./_client"; +import { GroupsList } from "./_client"; import classes from "./groups.module.css"; export default async function GroupsListPage() { @@ -26,7 +26,7 @@ export default async function GroupsListPage() { const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup); return ( - <ManagePageLayout title={tCommon("entity.groups")} primaryAction={<AddGroupButton />} floatingPrimaryAction> + <ManagePageLayout title={tCommon("entity.groups")}> <Stack> {dbEveryoneGroup && ( <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}> diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx index f84865ffb..42ca121fd 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-button.tsx @@ -2,18 +2,15 @@ import { Button } from "@mantine/core"; -import { useModalAction } from "@homarr/modals"; -import { InviteCreateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants"; -export const InviteCreateButton = () => { +export const InviteCreateButton = ({ onClick }: { onClick: () => void }) => { const t = useI18n("management.page.user.invite"); - const { openModal } = useModalAction(InviteCreateModal); return ( - <Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}> + <Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={onClick}> {t("action.new.title")} </Button> ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx new file mode 100644 index 000000000..c56aecf0b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-form.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Anchor, Button, CopyButton, Group, Mark, Stack, Text } from "@mantine/core"; +import { DateTimePicker } from "@mantine/dates"; +import dayjs from "dayjs"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { invariantTechnicalLabels } from "@homarr/definitions"; +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; + +type FormType = { + expirationDate: string; +}; + +type CreatedInvite = RouterOutputs["invite"]["createInvite"]; + +interface InviteCreateFormProps { + onClose: () => void; +} + +export const InviteCreateForm = ({ onClose }: InviteCreateFormProps) => { + const tInvite = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); + const [createdInvite, setCreatedInvite] = useState<CreatedInvite>(); + const utils = clientApi.useUtils(); + const { mutate, isPending } = clientApi.invite.createInvite.useMutation(); + const minDate = dayjs().add(1, "hour").toDate(); + const maxDate = dayjs().add(6, "months").toDate(); + const form = useForm<FormType>({ + initialValues: { + expirationDate: dayjs().add(4, "hours").toDate().toISOString(), + }, + }); + + if (createdInvite) { + return <CreatedInviteDetails invite={createdInvite} onClose={onClose} />; + } + + return ( + <form + onSubmit={form.onSubmit((values) => { + mutate( + { + expirationDate: new Date(values.expirationDate), + }, + { + onSuccess: (result) => { + void utils.invite.getAll.invalidate(); + setCreatedInvite(result); + }, + }, + ); + })} + > + <Stack> + <Text>{tInvite("action.new.description")}</Text> + <DateTimePicker + popoverProps={{ withinPortal: true }} + minDate={minDate} + maxDate={maxDate} + withAsterisk + valueFormat="DD MMM YYYY HH:mm" + label={tInvite("field.expirationDate.label")} + variant="filled" + {...form.getInputProps("expirationDate")} + /> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={onClose}> + {tCommon("action.cancel")} + </Button> + <Button type="submit" loading={isPending}> + {tCommon("action.create")} + </Button> + </Group> + </Stack> + </form> + ); +}; + +const CreatedInviteDetails = ({ invite, onClose }: { invite: CreatedInvite; onClose: () => void }) => { + const tInvite = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); + const inviteUrl = createInviteUrl(invite); + + return ( + <Stack component="output" aria-live="polite"> + <Text> + {tInvite.rich("action.copy.description", { + b: bold, + })} + </Text> + <Stack gap="xs"> + <Text fw="bold">{tInvite("action.copy.link")}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + <Anchor href={inviteUrl} target="_blank" rel="noreferrer"> + {inviteUrl} + </Anchor> + </Mark> + <Text fw="bold">{invariantTechnicalLabels.id}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + {invite.id} + </Mark> + <Text fw="bold">{tInvite("field.token.label")}:</Text> + <Mark style={{ borderRadius: 4, overflowWrap: "anywhere" }} color="gray" px={5}> + {invite.token} + </Mark> + </Stack> + <Group justify="end" wrap="wrap"> + <Button variant="default" onClick={onClose}> + {tCommon("action.close")} + </Button> + <CopyButton value={inviteUrl}> + {({ copy }) => ( + <Button + onClick={() => { + copy(); + onClose(); + }} + > + {tInvite("action.copy.button")} + </Button> + )} + </CopyButton> + </Group> + </Stack> + ); +}; + +const bold = (children: ReactNode) => <b>{children}</b>; + +const createInviteUrl = ({ id, token }: CreatedInvite) => { + const path = `/auth/invite/${id}?token=${token}`; + return new URL(path, window.location.origin).toString(); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx index 09ca354d2..7502fa6c2 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -1,7 +1,8 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { ActionIcon } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Card, Collapse, Group, Stack } from "@mantine/core"; import { IconTrash } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; @@ -11,9 +12,11 @@ import { MantineReactTable } from "mantine-react-table"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { invariantTechnicalLabels } from "@homarr/definitions"; -import { useConfirmModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; +import { InviteCreateButton } from "./invite-create-button"; +import { InviteCreateForm } from "./invite-create-form"; dayjs.extend(relativeTime); @@ -23,6 +26,10 @@ interface InviteListComponentProps { export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => { const t = useI18n("management.page.user.invite"); + const router = useRouter(); + const searchParams = useSearchParams(); + const [isCreateOpen, setIsCreateOpen] = useState(searchParams.get("create") === "true"); + const [formKey, setFormKey] = useState(0); const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, { initialData: initialInvites, refetchOnMount: false, @@ -73,28 +80,65 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps }, }); - return <MantineReactTable table={table} />; + useEffect(() => { + if (searchParams.get("create") !== "true") return; + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }, [searchParams]); + + const closeCreate = () => { + setIsCreateOpen(false); + setFormKey((value) => value + 1); + if (searchParams.has("create")) router.replace("/manage/users/invites", { scroll: false }); + }; + + const toggleCreate = () => { + if (isCreateOpen) { + closeCreate(); + return; + } + + setFormKey((value) => value + 1); + setIsCreateOpen(true); + }; + + return ( + <Stack> + <Group justify="end"> + <InviteCreateButton onClick={toggleCreate} /> + </Group> + <Collapse expanded={isCreateOpen}> + <Card withBorder> + <InviteCreateForm key={formKey} onClose={closeCreate} /> + </Card> + </Collapse> + <MantineReactTable table={table} /> + </Stack> + ); }; const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => { const t = useI18n("management.page.user.invite"); + const tCommon = useI18n("common"); const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation(); const utils = clientApi.useUtils(); - const { openConfirmModal } = useConfirmModal(); const handleDelete = useCallback(() => { - openConfirmModal({ - title: t("action.delete.title"), - children: t("action.delete.description"), - onConfirm: () => { - mutate({ id: row.original.id }); - void utils.invite.getAll.invalidate(); - }, - }); - }, [openConfirmModal, row.original.id, mutate, utils, t]); + mutate({ id: row.original.id }); + void utils.invite.getAll.invalidate(); + }, [row.original.id, mutate, utils]); return ( - <ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}> + <InlineConfirmActionIcon + variant="subtle" + color="red" + onConfirm={handleDelete} + confirmLabel={tCommon("action.confirm")} + confirmationAriaLabel={tCommon("action.confirm")} + loading={isPending} + aria-label={t("action.delete.title")} + > <IconTrash color="red" size={20} stroke={1.5} /> - </ActionIcon> + </InlineConfirmActionIcon> ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx index 85dea15a8..5f6656e64 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx @@ -5,9 +5,7 @@ import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { getI18n } from "@homarr/translation/server"; -import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; -import { InviteCreateButton } from "./_components/invite-create-button"; import { InviteListComponent } from "./_components/invite-list"; export default async function InvitesOverviewPage() { @@ -24,15 +22,7 @@ export default async function InvitesOverviewPage() { const initialInvites = await api.invite.getAll(); return ( - <ManagePageLayout - title={t("title")} - primaryAction={ - <ManageMobilePrimaryAction> - <InviteCreateButton /> - </ManageMobilePrimaryAction> - } - floatingPrimaryAction - > + <ManagePageLayout title={t("title")}> <InviteListComponent initialInvites={initialInvites} /> </ManagePageLayout> ); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index 551f2d656..b19b71919 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -20,8 +20,8 @@ import { WidgetError } from "@homarr/widgets/errors"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "~/components/board/items/lazy-widget-edit-modal"; -import type { Dimensions } from "./_dimension-modal"; -import { PreviewDimensionsModal } from "./_dimension-modal"; +import type { Dimensions } from "./_dimension-popover"; +import { PreviewDimensionsPopover } from "./_dimension-popover"; interface WidgetPreviewPageContentProps { kind: WidgetKind; @@ -32,7 +32,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps const t = useI18n(); const utils = clientApi.useUtils(); const { openModal: openWidgetEditModal } = useModalAction(LazyWidgetEditModal); - const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { definition: currentDefinition, Component } = use(loadWidgetResources(kind)); const [editMode, setEditMode] = useState(false); const [isEditorLoading, setIsEditorLoading] = useState(false); @@ -98,13 +97,6 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps }); }, [editMode, t]); - const openDimensionsModal = useCallback(() => { - openPreviewDimensionsModal({ - dimensions, - setDimensions, - }); - }, [dimensions, openPreviewDimensionsModal]); - const updateOptions = useCallback( ({ newOptions }: { newOptions: Record<string, unknown> }) => setState((current) => ({ ...current, options: { ...current.options, ...newOptions } })), @@ -162,15 +154,21 @@ export const WidgetPreviewPageContent = ({ kind }: WidgetPreviewPageContentProps </ActionIcon> </Affix> <Affix bottom={12} right={72 + 120}> - <ActionIcon - size={48} - variant="default" - radius="xl" - onClick={openDimensionsModal} - aria-label={t("widgetPreview.dimensions.title")} - > - <IconDimensions size={24} /> - </ActionIcon> + <PreviewDimensionsPopover + dimensions={dimensions} + setDimensions={setDimensions} + target={(onClick) => ( + <ActionIcon + size={48} + variant="default" + radius="xl" + aria-label={t("widgetPreview.dimensions.title")} + onClick={onClick} + > + <IconDimensions size={24} /> + </ActionIcon> + )} + /> </Affix> </> ); diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx deleted file mode 100644 index e8466f116..000000000 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-modal.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client"; - -import { Button, Group, InputWrapper, Slider, Stack } from "@mantine/core"; - -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface InnerProps { - dimensions: Dimensions; - setDimensions: (dimensions: Dimensions) => void; -} - -export const PreviewDimensionsModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n(); - const form = useForm({ - initialValues: innerProps.dimensions, - }); - - const handleSubmit = (values: Dimensions) => { - innerProps.setDimensions(values); - actions.closeModal(); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <InputWrapper label={t("item.moveResize.field.width.label")}> - <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} /> - </InputWrapper> - <InputWrapper label={t("item.moveResize.field.height.label")}> - <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} /> - </InputWrapper> - <Group justify="end"> - <Button variant="subtle" color="gray" onClick={actions.closeModal}> - {t("common.action.cancel")} - </Button> - <Button type="submit">{t("common.action.confirm")}</Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("widgetPreview.dimensions.title"), -}); - -export interface Dimensions { - width: number; - height: number; -} diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx new file mode 100644 index 000000000..46f3e8f8d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_dimension-popover.tsx @@ -0,0 +1,78 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Button, Group, InputWrapper, Popover, Slider, Stack } from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; + +import { useForm } from "@homarr/form"; +import { useI18n } from "@homarr/translation/client"; + +interface PreviewDimensionsPopoverProps { + target: (onClick: () => void) => ReactNode; + dimensions: Dimensions; + setDimensions: (dimensions: Dimensions) => void; +} + +export const PreviewDimensionsPopover = ({ target, dimensions, setDimensions }: PreviewDimensionsPopoverProps) => { + const t = useI18n(); + const [opened, { close, open }] = useDisclosure(false); + const form = useForm({ + initialValues: dimensions, + }); + + const handleSubmit = (values: Dimensions) => { + setDimensions(values); + close(); + }; + + const handleClose = () => { + form.setValues(dimensions); + close(); + }; + + const handleToggle = () => { + if (opened) { + handleClose(); + return; + } + open(); + }; + + return ( + <Popover + opened={opened} + onChange={(nextOpened) => { + if (nextOpened) open(); + if (!nextOpened) handleClose(); + }} + position="top-end" + width="min(320px, calc(100vw - 2rem))" + withArrow + > + <Popover.Target>{target(handleToggle)}</Popover.Target> + <Popover.Dropdown> + <form onSubmit={form.onSubmit(handleSubmit)}> + <Stack> + <InputWrapper label={t("item.moveResize.field.width.label")}> + <Slider min={64} max={1024} step={64} {...form.getInputProps("width")} /> + </InputWrapper> + <InputWrapper label={t("item.moveResize.field.height.label")}> + <Slider min={64} max={1024} step={64} {...form.getInputProps("height")} /> + </InputWrapper> + <Group justify="end"> + <Button variant="subtle" color="gray" onClick={handleClose}> + {t("common.action.cancel")} + </Button> + <Button type="submit">{t("common.action.confirm")}</Button> + </Group> + </Stack> + </form> + </Popover.Dropdown> + </Popover> + ); +}; + +export interface Dimensions { + width: number; + height: number; +} diff --git a/apps/nextjs/src/components/access/access-picker.tsx b/apps/nextjs/src/components/access/access-picker.tsx new file mode 100644 index 000000000..98e8b8f04 --- /dev/null +++ b/apps/nextjs/src/components/access/access-picker.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import type { ReactNode } from "react"; +import { Button, Center, Combobox, Group, Loader, ScrollArea, useCombobox } from "@mantine/core"; +import type { ButtonProps } from "@mantine/core"; + +export interface AccessPickerOption<T> { + value: string; + label: string; + keywords?: readonly string[]; + content: ReactNode; + item: T; +} + +export type AccessPickerTriggerProps = Omit<ButtonProps, "children" | "loading" | "onClick" | "type">; + +interface AccessPickerProps<T> { + label: string; + searchPlaceholder: string; + emptyMessage: string; + triggerLabel: string; + options: AccessPickerOption<T>[]; + isPending: boolean; + onSelect: (option: AccessPickerOption<T>) => void | Promise<void>; + triggerProps?: AccessPickerTriggerProps; +} + +export const AccessPicker = <T,>({ + label, + searchPlaceholder, + emptyMessage, + triggerLabel, + options, + isPending, + onSelect, + triggerProps, +}: AccessPickerProps<T>) => { + const [search, setSearch] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const combobox = useCombobox({ + onDropdownClose: () => { + combobox.resetSelectedOption(); + setSearch(""); + requestAnimationFrame(() => combobox.focusTarget()); + }, + onDropdownOpen: () => { + combobox.selectFirstOption(); + requestAnimationFrame(() => combobox.focusSearchInput()); + }, + }); + + const normalizedSearch = search.trim().toLowerCase(); + const filteredOptions = options.filter((option) => { + if (!normalizedSearch) return true; + + const searchableText = [option.label, ...(option.keywords ?? [])].join(" ").toLowerCase(); + return searchableText.includes(normalizedSearch); + }); + const visibleOptions = filteredOptions.slice(0, 5); + + const handleSelect = (value: string) => { + const option = options.find((item) => item.value === value); + if (!option || isSubmitting) return; + + combobox.closeDropdown(); + setIsSubmitting(true); + + const submit = async () => { + try { + await onSelect(option); + } finally { + setIsSubmitting(false); + } + }; + + void submit().catch(() => undefined); + }; + + return ( + <Combobox store={combobox} onOptionSubmit={handleSelect} width="target" withinPortal> + <Combobox.Target targetType="button"> + <Button + {...triggerProps} + onClick={() => combobox.toggleDropdown()} + rightSection={ + <Group gap={4} wrap="nowrap"> + {triggerProps?.rightSection} + <Combobox.Chevron /> + </Group> + } + loading={isSubmitting} + disabled={isSubmitting || triggerProps?.disabled} + > + {triggerLabel} + </Button> + </Combobox.Target> + + <Combobox.Dropdown> + <Combobox.Search + value={search} + onChange={(event) => { + setSearch(event.currentTarget.value); + combobox.updateSelectedOptionIndex(); + }} + placeholder={searchPlaceholder} + aria-label={label} + /> + <ScrollArea.Autosize mah={300} type="auto"> + <Combobox.Options> + {isPending && ( + <Center p="md"> + <LoaderPlaceholder /> + </Center> + )} + {!isPending && filteredOptions.length === 0 && <Combobox.Empty>{emptyMessage}</Combobox.Empty>} + {!isPending && + visibleOptions.map((option) => ( + <Combobox.Option value={option.value} key={option.value}> + {option.content} + </Combobox.Option> + ))} + </Combobox.Options> + </ScrollArea.Autosize> + </Combobox.Dropdown> + </Combobox> + ); +}; + +const LoaderPlaceholder = () => <Loader size="sm" />; diff --git a/apps/nextjs/src/components/access/group-access-form.tsx b/apps/nextjs/src/components/access/group-access-form.tsx index 4963887f6..d71495e56 100644 --- a/apps/nextjs/src/components/access/group-access-form.tsx +++ b/apps/nextjs/src/components/access/group-access-form.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Anchor, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; @@ -11,7 +10,7 @@ import { AccessSelectRow } from "./access-table-rows"; import { useAccessContext } from "./context"; import type { AccessFormType } from "./form"; import { FormProvider, useForm } from "./form"; -import { GroupSelectModal } from "./group-select-modal"; +import { GroupSelect } from "./group-select"; import type { FormProps } from "./user-access-form"; export const GroupAccessForm = <TPermission extends string>({ @@ -24,8 +23,6 @@ export const GroupAccessForm = <TPermission extends string>({ const [groups, setGroups] = useState<Map<string, AccessQueryData<string>["groups"][number]["group"]>>( new Map(accessQueryData.groups.map(({ group }) => [group.id, group])), ); - const { openModal } = useModalAction(GroupSelectModal); - const tCommon = useI18n("common"); const tPermissions = useI18n("permission"); const form = useForm({ initialValues: { @@ -36,23 +33,6 @@ export const GroupAccessForm = <TPermission extends string>({ }, }); - const handleAddUser = () => { - openModal({ - presentGroupIds: form.values.items.map(({ principalId: id }) => id), - onSelect: (group) => { - setGroups((prev) => new Map(prev).set(group.id, group)); - form.setFieldValue("items", [ - { - principalId: group.id, - permission: defaultPermission, - }, - ...form.values.items, - ]); - handleCountChange((prev) => prev + 1); - }, - }); - }; - return ( <form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> <FormProvider form={form}> @@ -79,9 +59,21 @@ export const GroupAccessForm = <TPermission extends string>({ </Table> <Group justify="space-between"> - <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> - {tCommon("action.add")} - </Button> + <GroupSelect + presentGroupIds={form.values.items.map(({ principalId: id }) => id)} + onSelect={(group) => { + setGroups((prev) => new Map(prev).set(group.id, group)); + form.setFieldValue("items", [ + { + principalId: group.id, + permission: defaultPermission, + }, + ...form.values.items, + ]); + handleCountChange((prev) => prev + 1); + }} + triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }} + /> <Button type="submit" loading={isPending}> {tPermissions("action.saveGroup")} </Button> diff --git a/apps/nextjs/src/components/access/group-select-modal.tsx b/apps/nextjs/src/components/access/group-select-modal.tsx deleted file mode 100644 index d66f9f733..000000000 --- a/apps/nextjs/src/components/access/group-select-modal.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useState } from "react"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import type { GroupPermissionKey } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -interface InnerProps { - withPermissions?: boolean; - presentGroupIds: string[]; - onSelect: (props: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; - confirmLabel?: string; -} - -interface GroupSelectFormType { - groupId: string; -} - -export const GroupSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { - const tGroup = useI18n("group.action.select"); - const tCommon = useI18n("common"); - const { data: groups, isPending } = clientApi.group.selectable.useQuery({ - withPermissions: innerProps.withPermissions, - }); - const [loading, setLoading] = useState(false); - const form = useForm<GroupSelectFormType>(); - const handleSubmitAsync = async (values: GroupSelectFormType) => { - const currentGroup = groups?.find((group) => group.id === values.groupId); - if (!currentGroup) return; - setLoading(true); - await innerProps.onSelect({ - id: currentGroup.id, - name: currentGroup.name, - permissions: "permissions" in currentGroup ? (currentGroup.permissions as GroupPermissionKey[]) : undefined, - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add"); - - return ( - <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> - <Stack> - <Select - {...form.getInputProps("groupId")} - label={tGroup("label")} - clearable - searchable - leftSection={isPending ? <Loader size="xs" /> : undefined} - nothingFoundMessage={tGroup("notFound")} - limit={5} - data={groups - ?.filter((group) => !innerProps.presentGroupIds.includes(group.id)) - .map((group) => ({ value: group.id, label: group.name }))} - /> - <Group justify="end"> - <Button variant="default" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={loading}> - {confirmLabel} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("permission.groupSelect.title"), -}); diff --git a/apps/nextjs/src/components/access/group-select.tsx b/apps/nextjs/src/components/access/group-select.tsx new file mode 100644 index 000000000..476c96224 --- /dev/null +++ b/apps/nextjs/src/components/access/group-select.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Text } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import type { GroupPermissionKey } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker"; +import { AccessPicker } from "./access-picker"; + +type SelectableGroup = RouterOutputs["group"]["selectable"][number]; + +interface GroupSelectProps { + withPermissions?: boolean; + presentGroupIds: string[]; + onSelect: (group: { id: string; name: string; permissions?: GroupPermissionKey[] }) => void | Promise<void>; + triggerLabel?: string; + triggerProps?: AccessPickerTriggerProps; +} + +export const GroupSelect = ({ + withPermissions, + presentGroupIds, + onSelect, + triggerLabel, + triggerProps, +}: GroupSelectProps) => { + const tGroup = useI18n("group.action.select"); + const tCommon = useI18n("common"); + const { data: groups, isPending } = clientApi.group.selectable.useQuery({ withPermissions }); + const availableGroups = (groups ?? []).filter((group) => !presentGroupIds.includes(group.id)); + const options: AccessPickerOption<SelectableGroup>[] = availableGroups.map((group) => ({ + value: group.id, + label: group.name, + keywords: getGroupPermissions(group) ?? [], + item: group, + content: <Text size="sm">{group.name}</Text>, + })); + + const handleSelect = ({ item: group }: AccessPickerOption<SelectableGroup>) => + onSelect({ + id: group.id, + name: group.name, + permissions: getGroupPermissions(group), + }); + + return ( + <AccessPicker + label={tGroup("label")} + searchPlaceholder={tGroup("label")} + emptyMessage={tGroup("notFound")} + triggerLabel={triggerLabel ?? tCommon("action.add")} + triggerProps={triggerProps} + options={options} + isPending={isPending} + onSelect={handleSelect} + /> + ); +}; + +const getGroupPermissions = (group: SelectableGroup) => { + if ("permissions" in group) return group.permissions as GroupPermissionKey[]; + return undefined; +}; diff --git a/apps/nextjs/src/components/access/user-access-form.tsx b/apps/nextjs/src/components/access/user-access-form.tsx index af85d1e61..8f7126992 100644 --- a/apps/nextjs/src/components/access/user-access-form.tsx +++ b/apps/nextjs/src/components/access/user-access-form.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Anchor, Box, Button, Group, Stack, Table, TableTbody, TableTh, TableThead, TableTr } from "@mantine/core"; import { IconPlus } from "@tabler/icons-react"; -import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; @@ -11,7 +10,7 @@ import { AccessDisplayRow, AccessSelectRow } from "./access-table-rows"; import { useAccessContext } from "./context"; import type { AccessFormType, HandleCountChange } from "./form"; import { FormProvider, useForm } from "./form"; -import { UserSelectModal } from "./user-select-modal"; +import { UserSelect } from "./user-select"; export interface FormProps<TPermission extends string> { entity: { @@ -41,8 +40,6 @@ export const UsersAccessForm = <TPermission extends string>({ const [users, setUsers] = useState<Map<string, UserItemContentProps["user"]>>( new Map(accessQueryData.users.map(({ user }) => [user.id, user])), ); - const { openModal } = useModalAction(UserSelectModal); - const tCommon = useI18n("common"); const tPermissions = useI18n("permission"); const form = useForm({ initialValues: { @@ -52,25 +49,8 @@ export const UsersAccessForm = <TPermission extends string>({ })), }, }); - - const handleAddUser = () => { - const presentUserIds = form.values.items.map(({ principalId: id }) => id); - - openModal({ - presentUserIds: entity.ownerId ? presentUserIds.concat(entity.ownerId) : presentUserIds, - onSelect: (user) => { - setUsers((prev) => new Map(prev).set(user.id, user)); - form.setFieldValue("items", [ - { - principalId: user.id, - permission: defaultPermission, - }, - ...form.values.items, - ]); - handleCountChange((prev) => prev + 1); - }, - }); - }; + const presentUserIds = form.values.items.map(({ principalId: id }) => id); + if (entity.ownerId) presentUserIds.push(entity.ownerId); return ( <form onSubmit={form.onSubmit((values) => handleSubmit(values as AccessFormType<TPermission>))}> @@ -101,9 +81,21 @@ export const UsersAccessForm = <TPermission extends string>({ </Table> <Group justify="space-between"> - <Button rightSection={<IconPlus size="1rem" />} variant="light" onClick={handleAddUser}> - {tCommon("action.add")} - </Button> + <UserSelect + presentUserIds={presentUserIds} + onSelect={(user) => { + setUsers((prev) => new Map(prev).set(user.id, user)); + form.setFieldValue("items", [ + { + principalId: user.id, + permission: defaultPermission, + }, + ...form.values.items, + ]); + handleCountChange((prev) => prev + 1); + }} + triggerProps={{ rightSection: <IconPlus size="1rem" />, variant: "light" }} + /> <Button type="submit" loading={isPending}> {tPermissions("action.saveUser")} </Button> diff --git a/apps/nextjs/src/components/access/user-select-modal.tsx b/apps/nextjs/src/components/access/user-select-modal.tsx deleted file mode 100644 index 8ba1be5ec..000000000 --- a/apps/nextjs/src/components/access/user-select-modal.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState } from "react"; -import type { SelectProps } from "@mantine/core"; -import { Button, Group, Loader, Select, Stack } from "@mantine/core"; -import { IconCheck } from "@tabler/icons-react"; - -import type { RouterOutputs } from "@homarr/api"; -import { clientApi } from "@homarr/api/client"; -import type { SupportedAuthProvider } from "@homarr/definitions"; -import { useForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { UserAvatar } from "@homarr/ui"; - -interface InnerProps { - presentUserIds: string[]; - allowedProviders?: SupportedAuthProvider[]; - onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; - confirmLabel?: string; -} - -interface UserSelectFormType { - userId: string; -} - -export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => { - const tUser = useI18n("user.action.select"); - const tCommon = useI18n("common"); - const { data: users, isPending } = clientApi.user.selectable.useQuery({ - providers: innerProps.allowedProviders, - }); - const [loading, setLoading] = useState(false); - const form = useForm<UserSelectFormType>(); - const handleSubmitAsync = async (values: UserSelectFormType) => { - const currentUser = users?.find((user) => user.id === values.userId); - if (!currentUser) return; - setLoading(true); - await innerProps.onSelect({ - id: currentUser.id, - name: currentUser.name ?? "", - image: currentUser.image ?? "", - email: currentUser.email ?? null, - }); - - setLoading(false); - actions.closeModal(); - }; - - const confirmLabel = innerProps.confirmLabel ?? tCommon("action.add"); - const currentUser = users?.find((user) => user.id === form.values.userId); - - return ( - <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> - <Stack> - <Select - {...form.getInputProps("userId")} - label={tUser("label")} - searchable - clearable - leftSection={ - isPending ? <Loader size="xs" /> : currentUser ? <UserAvatar user={currentUser} size="xs" /> : undefined - } - nothingFoundMessage={tUser("notFound")} - renderOption={createRenderOption(users ?? [])} - limit={5} - data={users - ?.filter((user) => !innerProps.presentUserIds.includes(user.id)) - .map((user) => ({ value: user.id, label: user.name ?? "" }))} - /> - <Group justify="end"> - <Button variant="default" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={loading}> - {confirmLabel} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("permission.userSelect.title"), -}); - -const iconProps = { - stroke: 1.5, - color: "currentColor", - opacity: 0.6, - size: "1rem", -}; - -const createRenderOption = (users: RouterOutputs["user"]["selectable"]): SelectProps["renderOption"] => - function InnerRenderRoot({ option, checked }) { - const user = users.find((user) => user.id === option.value); - if (!user) return null; - - return ( - <Group flex="1" gap="xs"> - <UserAvatar user={user} size="xs" /> - {option.label} - {checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />} - </Group> - ); - }; diff --git a/apps/nextjs/src/components/access/user-select.tsx b/apps/nextjs/src/components/access/user-select.tsx new file mode 100644 index 000000000..c38bca1ce --- /dev/null +++ b/apps/nextjs/src/components/access/user-select.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { Group, Stack, Text } from "@mantine/core"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import type { SupportedAuthProvider } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { UserAvatar } from "@homarr/ui"; + +import type { AccessPickerOption, AccessPickerTriggerProps } from "./access-picker"; +import { AccessPicker } from "./access-picker"; + +type SelectableUser = RouterOutputs["user"]["selectable"][number]; + +interface UserSelectProps { + presentUserIds: string[]; + allowedProviders?: SupportedAuthProvider[]; + onSelect: (user: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>; + triggerLabel?: string; + triggerProps?: AccessPickerTriggerProps; +} + +export const UserSelect = ({ + presentUserIds, + allowedProviders, + onSelect, + triggerLabel, + triggerProps, +}: UserSelectProps) => { + const tUser = useI18n("user.action.select"); + const tCommon = useI18n("common"); + const { data: users, isPending } = clientApi.user.selectable.useQuery({ providers: allowedProviders }); + const availableUsers = (users ?? []).filter((user) => !presentUserIds.includes(user.id)); + const options: AccessPickerOption<SelectableUser>[] = availableUsers.map((user) => ({ + value: user.id, + label: user.name ?? "", + keywords: [user.email ?? ""], + item: user, + content: ( + <Group wrap="nowrap" gap="xs"> + <UserAvatar user={user} size="xs" /> + <Stack gap={0}> + <Text size="sm">{user.name}</Text> + {user.email && ( + <Text size="xs" c="dimmed"> + {user.email} + </Text> + )} + </Stack> + </Group> + ), + })); + + const handleSelect = ({ item: user }: AccessPickerOption<SelectableUser>) => + onSelect({ + id: user.id, + name: user.name ?? "", + image: user.image ?? "", + email: user.email ?? null, + }); + + return ( + <AccessPicker + label={tUser("label")} + searchPlaceholder={tUser("label")} + emptyMessage={tUser("notFound")} + triggerLabel={triggerLabel ?? tCommon("action.add")} + triggerProps={triggerProps} + options={options} + isPending={isPending} + onSelect={handleSelect} + /> + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index a8b8eea9b..df27b7856 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -130,8 +130,8 @@ import { useTimeAgo } from "@homarr/common"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useConfirmModal } from "@homarr/modals"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; import classes from "./assistant-panel.module.css"; import { getAssistantActivityState } from "./assistant-activity-state"; @@ -2001,7 +2001,6 @@ const ThreadListItem = () => { const t = useI18n("assistant"); const actionT = useI18n("common.action"); const aui = useAui(); - const { openConfirmModal } = useConfirmModal(); const onSelect = useContext(HistorySelectContext); const remoteId = useAuiState((state) => state.threadListItem.remoteId); const title = useAuiState((state) => state.threadListItem.title); @@ -2054,23 +2053,15 @@ const ThreadListItem = () => { } }; - const deleteConversation = () => { - openConfirmModal({ - title: t("deleteConversation.title"), - children: t("deleteConversation.description", { title: title ?? t("newConversation") }), - confirmProps: { color: "red" }, - labels: { confirm: actionT("delete"), cancel: actionT("cancel") }, - onConfirm: async () => { - try { - await aui.threadListItem().delete(); - } catch { - showErrorNotification({ - title: t("deleteConversation.failedTitle"), - message: t("deleteConversation.failedDescription"), - }); - } - }, - }); + const deleteConversation = async () => { + try { + await aui.threadListItem().delete(); + } catch { + showErrorNotification({ + title: t("deleteConversation.failedTitle"), + message: t("deleteConversation.failedDescription"), + }); + } }; if (renameOpened) { @@ -2152,16 +2143,19 @@ const ThreadListItem = () => { > <IconFileExport size={14} /> </ActionIcon> - <ActionIcon + <InlineConfirmActionIcon variant="subtle" color="red" size="sm" title={actionT("delete")} aria-label={actionT("delete")} - onClick={deleteConversation} + confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })} + confirmationAriaLabel={t("deleteConversation.title")} + confirmationChildren={<IconCheck size={14} />} + onConfirm={deleteConversation} > <IconTrash size={14} /> - </ActionIcon> + </InlineConfirmActionIcon> </Group> )} <ActionIcon diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index f32ece17c..bf87766a8 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -11,16 +11,17 @@ import classes from "./board-layout-thumbnail.module.css"; interface BoardLayoutThumbnailProps { preview: BoardPreviewData | null; label: string; - fitFullLayout?: boolean; + previewRowLimit?: number; className?: string; } -const maxThumbnailRows = 12; +const compactBoardLayoutThumbnailRows = 12; +export const maxBoardLayoutThumbnailRows = 48; export const BoardLayoutThumbnail = ({ preview, label, - fitFullLayout = false, + previewRowLimit = compactBoardLayoutThumbnailRows, className, }: BoardLayoutThumbnailProps) => { const canvasClassName = [classes.canvas, className].filter(Boolean).join(" "); @@ -34,7 +35,7 @@ export const BoardLayoutThumbnail = ({ } const elements = projectBoardLayout(preview, layout, layout); - const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows; + const thumbnailRowLimit = Math.max(1, Math.min(Math.floor(previewRowLimit), maxBoardLayoutThumbnailRows)); const roots = preview.sections .filter((section) => section.kind === "empty") .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); @@ -94,9 +95,21 @@ export const BoardLayoutThumbnail = ({ }} > {laneElements.map((element) => { + const renderedWidth = Math.min(element.width, columnCount - element.xOffset); + const renderedHeight = Math.min(element.height, rowCount - element.yOffset); const nestedItems = element.type === "section" - ? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id) + ? elements.filter( + (candidate) => + candidate.type === "item" && + candidate.sectionId === element.id && + candidate.xOffset >= 0 && + candidate.xOffset < renderedWidth && + candidate.yOffset >= 0 && + candidate.yOffset < renderedHeight && + candidate.width > 0 && + candidate.height > 0, + ) : []; return ( <span @@ -104,16 +117,16 @@ export const BoardLayoutThumbnail = ({ aria-hidden className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`} style={{ - gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`, - gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`, + gridColumn: `${element.xOffset + 1} / span ${renderedWidth}`, + gridRow: `${element.yOffset + 1} / span ${renderedHeight}`, }} > {element.type === "section" ? ( <span className={classes.containerContents} style={{ - gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`, - gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`, + gridTemplateColumns: `repeat(${renderedWidth}, minmax(0, 1fr))`, + gridTemplateRows: `repeat(${renderedHeight}, minmax(0, 1fr))`, }} > {nestedItems.map((nestedItem) => ( @@ -123,11 +136,11 @@ export const BoardLayoutThumbnail = ({ style={{ gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min( nestedItem.width, - element.width - nestedItem.xOffset, + renderedWidth - nestedItem.xOffset, )}`, gridRow: `${nestedItem.yOffset + 1} / span ${Math.min( nestedItem.height, - element.height - nestedItem.yOffset, + renderedHeight - nestedItem.yOffset, )}`, }} > diff --git a/apps/nextjs/src/components/board/board-switcher.module.css b/apps/nextjs/src/components/board/board-switcher.module.css index 6843fe497..e4bbc3aea 100644 --- a/apps/nextjs/src/components/board/board-switcher.module.css +++ b/apps/nextjs/src/components/board/board-switcher.module.css @@ -15,6 +15,10 @@ outline: none; } +.results { + min-height: 0; +} + .searchInput { border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent); @@ -44,3 +48,19 @@ border-color: var(--mantine-primary-color-filled); box-shadow: var(--mantine-shadow-md); } + +@media (max-width: $mantine-breakpoint-sm) { + .modalContent { + height: calc(100dvh - var(--modal-y-offset) * 2); + } + + .modalBody, + .content { + min-height: 0; + height: 100%; + } + + .results { + flex: 1; + } +} diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index 8b9505aae..14dfaa42c 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -30,11 +30,12 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { Link, UserAvatar } from "@homarr/ui"; -import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; +import { BoardLayoutThumbnail, maxBoardLayoutThumbnailRows } from "~/components/board/board-layout-thumbnail"; import classes from "./board-switcher.module.css"; export const boardSwitcherHotkey = "shift+c"; +const boardSwitcherPreview = { previewRowLimit: maxBoardLayoutThumbnailRows } as const; export interface BoardSwitcherControls { open: () => void; @@ -61,18 +62,12 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { data: boards = [], isPending, isError, - } = clientApi.board.getManageOverview.useQuery( - { fullPreview: true }, - { - enabled: isOpen, - }, - ); + } = clientApi.board.getManageOverview.useQuery(boardSwitcherPreview, { + enabled: isOpen, + }); const switcherBoards = useMemo( - () => [ - ...boards.filter((board) => board.id !== currentBoard?.id), - ...boards.filter((board) => board.id === currentBoard?.id), - ], + () => boards.filter((board) => board.id !== currentBoard?.id), [boards, currentBoard?.id], ); const filteredBoards = useMemo(() => { @@ -91,7 +86,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { setSearch(""); setActiveIndex(0); }, []); - const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); + const preloadBoards = () => void utils.board.getManageOverview.prefetch(boardSwitcherPreview); const spotlightAction = useMemo( () => ({ @@ -228,7 +223,13 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { radius="xl" classNames={{ input: classes.searchInput }} /> - <ScrollArea.Autosize mah="min(80vh, 42rem)" type="never"> + <ScrollArea.Autosize + mah="min(80vh, 42rem)" + type="never" + scrollbars="y" + overscrollBehavior="contain" + className={classes.results} + > {results} </ScrollArea.Autosize> </Stack> @@ -335,7 +336,7 @@ const getBoardSwitcherResults = ({ <BoardLayoutThumbnail preview={board.preview} label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })} - fitFullLayout + previewRowLimit={boardSwitcherPreview.previewRowLimit} /> </Card.Section> <Card.Section withBorder px="sm" py={6}> 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<Record<string, unknown> | null>; widgetRuntimeRef: WidgetRuntimeRef; displayMode: "compact" | "advanced"; diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 1b18dd5a9..e1ac6524b 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { ActionIcon, Menu } from "@mantine/core"; -import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; +import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { useSession } from "@homarr/auth/client"; import { useEditMode } from "@homarr/boards/edit-mode"; @@ -8,20 +8,21 @@ import { getWidgetName } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmMenuItem } from "@homarr/ui"; import type { WidgetDefinition } from "@homarr/widgets/definition"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; -import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; -import { useOpenItemMoveModal } from "./item-move-modal"; import itemContentClasses from "./item-content.module.css"; +import { useOpenItemMoveModal } from "./item-move-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; interface BoardItemMenuProps { item: SectionItem; definition: WidgetDefinition; - previewDimensions: { width: number; height: number; scale?: number }; + previewDimensions: WidgetPreviewDimensions; resetErrorBoundary?: () => void; } @@ -142,11 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou </Menu.Item> )} <Menu.Divider /> - <BoardRemoveConfirmationMenuItem - label={tItem("action.remove")} - confirmationLabel={tItem("remove.message")} + <InlineConfirmMenuItem + color="red" + confirmLabel={tItem("remove.message")} + leftSection={<IconTrash size={16} />} onConfirm={() => removeItem({ itemId: item.id })} - /> + > + {tItem("action.remove")} + </InlineConfirmMenuItem> </Menu.Dropdown> </Menu> ); diff --git a/apps/nextjs/src/components/board/items/item-move-modal.tsx b/apps/nextjs/src/components/board/items/item-move-modal.tsx index c0beae1fd..3c42b44b5 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client"; import type { Board, ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; import { getLayoutRowCount } from "../layout"; import { resolvePinnedGridCollisions } from "../sections/grid/dnd"; +import type { RegisteredGridEditor } from "../sections/grid/grid-editor-registry"; import { useRegisteredGridEditors } from "../sections/grid/grid-editor-registry"; import { getSectionGridColumnCount, getSectionGridPlacements } from "../sections/grid/section-grid-placements"; import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions"; @@ -30,6 +31,7 @@ interface InnerProps extends OpenItemMoveModalInput { board: Board; currentLayoutId: string; commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void; + registeredGridEditors: ReadonlyMap<string, Pick<RegisteredGridEditor, "placementMaxRowCount">>; } interface MoveTarget { @@ -57,8 +59,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) = const tLandmark = useI18n("board.landmark"); const tContainer = useI18n("section.container"); const tMoveResize = useI18n("item.moveResize"); - const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps; - const registeredGridEditors = useRegisteredGridEditors(); + const { board, commitSectionGrids, currentLayoutId, entry, registeredGridEditors, sourceSectionId } = innerProps; const minimumSize = useMemo( () => getEntryMinimumSize(board, currentLayoutId, entry), [board, currentLayoutId, entry], @@ -235,6 +236,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) = export const useOpenItemMoveModal = () => { const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); + const registeredGridEditors = useRegisteredGridEditors(); const { commitSectionGrids } = useGridLayoutActions(); const { openModal } = useModalAction(ItemMoveModal); @@ -245,9 +247,10 @@ export const useOpenItemMoveModal = () => { board, currentLayoutId, commitSectionGrids, + registeredGridEditors, }); }, - [board, commitSectionGrids, currentLayoutId, openModal], + [board, commitSectionGrids, currentLayoutId, openModal, registeredGridEditors], ); }; diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.module.css b/apps/nextjs/src/components/board/items/widget-context-menu.module.css new file mode 100644 index 000000000..80c9cf80d --- /dev/null +++ b/apps/nextjs/src/components/board/items/widget-context-menu.module.css @@ -0,0 +1,14 @@ +.mobileDrawerContent { + height: min(80dvh, rem(640px)); + max-height: 80dvh; + overscroll-behavior: contain; +} + +.mobileDrawerBody { + padding-bottom: max(var(--mantine-spacing-md), env(safe-area-inset-bottom)); +} + +.mobileActionList { + display: flex; + flex-direction: column; +} diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index a4fc9b52a..5057907d2 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -2,7 +2,7 @@ import type { MutableRefObject, ReactNode, RefObject } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; +import { Drawer, Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query"; @@ -20,10 +20,12 @@ import { getWidgetName } from "@homarr/definitions"; import { translateIfNecessary } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; +import { useIsMobile } from "@homarr/ui/hooks"; import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation"; +import type { WidgetPreviewDimensions } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { useAdvancedFocus } from "../advanced-focus/context"; @@ -31,11 +33,14 @@ import { useBoardPermissions } from "../permissions/client"; import { useItemActions } from "./item-actions"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { matchesWidgetItemQuery } from "./widget-query-scope"; +import classes from "./widget-context-menu.module.css"; + +type ToggleOption = [key: string, option: { type: string; skipContextMenu?: boolean }]; interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; - previewDimensions: { width: number; height: number; scale?: number }; + previewDimensions: WidgetPreviewDimensions; widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetRuntimeRef: WidgetRuntimeRef; sourceRef: RefObject<HTMLElement | null>; @@ -134,11 +139,12 @@ export const WidgetContextMenu = ({ [item.id, persistBoard], ); - type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { if (!canConfigureWidget) return []; - const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>; - return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu); + const rawOptions = definition.createOptions(settings) as unknown as Record<string, ToggleOption[1]>; + return Object.entries(rawOptions).filter( + ([, option]) => option.type === "switch" && !option.skipContextMenu, + ) as ToggleOption[]; }, [canConfigureWidget, definition, settings]); const widgetContextActions = @@ -222,6 +228,18 @@ export const WidgetContextMenu = ({ [setItemOptions], ); + const handleOpenAdvancedFocus = useCallback(() => { + if (!sourceRef.current) return; + openAdvancedFocus(item.id, sourceRef.current, { + restoreFocusTarget: + sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current, + }); + }, [item.id, openAdvancedFocus, sourceRef]); + + const handleCloseMenu = useCallback(() => { + setMenuOpened(false); + }, []); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") { return <>{children}</>; } @@ -239,21 +257,10 @@ export const WidgetContextMenu = ({ onChange={setMenuOpened} > <Menu.ContextMenu>{children}</Menu.ContextMenu> - <Menu.Dropdown> + <WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}> {canOpenAdvancedFocus && ( <> - <Menu.Item - closeMenuOnClick - leftSection={<IconMaximize size={16} />} - onClick={() => { - if (sourceRef.current) - openAdvancedFocus(item.id, sourceRef.current, { - restoreFocusTarget: - sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? - sourceRef.current, - }); - }} - > + <Menu.Item closeMenuOnClick leftSection={<IconMaximize size={16} />} onClick={handleOpenAdvancedFocus}> {t("item.advancedFocus.open")} </Menu.Item> <Menu.Divider /> @@ -319,11 +326,39 @@ export const WidgetContextMenu = ({ > {tMenu("settings")} </Menu.Item> - </Menu.Dropdown> + </WidgetContextMenuDropdown> </Menu> ); }; +interface WidgetContextMenuDropdownProps { + opened: boolean; + onClose: () => void; + title: string; + children: ReactNode; +} + +const WidgetContextMenuDropdown = ({ opened, onClose, title, children }: WidgetContextMenuDropdownProps) => { + const isMobile = useIsMobile(); + + if (!isMobile) return <Menu.Dropdown>{children}</Menu.Dropdown>; + + return ( + <Drawer + opened={opened} + onClose={onClose} + position="bottom" + size="min(80dvh, 40rem)" + title={title} + classNames={{ content: classes.mobileDrawerContent, body: classes.mobileDrawerBody }} + > + <div className={classes.mobileActionList} role="menu" aria-orientation="vertical" data-menu-dropdown> + {children} + </div> + </Drawer> + ); +}; + interface WidgetQueryStatusProps { queryClient: QueryClient; matchesQuery: (queryKey: QueryKey) => boolean; 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/apps/nextjs/src/components/board/modals/board-rename-modal.tsx b/apps/nextjs/src/components/board/modals/board-rename-modal.tsx deleted file mode 100644 index 4e91146fb..000000000 --- a/apps/nextjs/src/components/board/modals/board-rename-modal.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import { Button, Group, Stack, TextInput } from "@mantine/core"; -import type { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { boardRenameSchema } from "@homarr/validation/board"; - -interface InnerProps { - id: string; - previousName: string; - onSuccess?: (name: string) => void; -} - -export const BoardRenameModal = createModal<InnerProps>(({ actions, innerProps }) => { - const utils = clientApi.useUtils(); - const tCommon = useI18n("common"); - const { mutate, isPending } = clientApi.board.renameBoard.useMutation({ - onSettled() { - void utils.board.getBoardByName.invalidate({ - name: innerProps.previousName, - }); - void utils.board.getHomeBoard.invalidate(); - void utils.board.getAllBoards.invalidate(); - }, - }); - const form = useZodForm(boardRenameSchema.omit({ id: true }), { - initialValues: { - name: innerProps.previousName, - }, - }); - - const handleSubmit = (values: FormType) => { - mutate( - { - id: innerProps.id, - name: values.name, - }, - { - onSuccess: () => { - actions.closeModal(); - innerProps.onSuccess?.(values.name); - }, - }, - ); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <TextInput label={tCommon("field.name")} {...form.getInputProps("name")} data-autofocus /> - <Group justify="end"> - <Button variant="subtle" color="gray" onClick={actions.closeModal}> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={isPending}> - {tCommon("action.confirm")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("board.setting.section.dangerZone.action.rename.label"), -}); - -type FormType = Omit<z.infer<typeof boardRenameSchema>, "id">; diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx index 7e5284f1f..7e4e180ed 100644 --- a/apps/nextjs/src/components/board/sections/container-section.tsx +++ b/apps/nextjs/src/components/board/sections/container-section.tsx @@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => { <Button className={classes.containerToggle} pos="absolute" - top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"} + top={0} left={0} w="100%" h={isVisuallyCollapsed ? "100%" : 24} diff --git a/apps/nextjs/src/components/board/sections/container/container-menu.tsx b/apps/nextjs/src/components/board/sections/container/container-menu.tsx index dfbc36f94..0e415b1ae 100644 --- a/apps/nextjs/src/components/board/sections/container/container-menu.tsx +++ b/apps/nextjs/src/components/board/sections/container/container-menu.tsx @@ -1,12 +1,12 @@ import { ActionIcon, Menu } from "@mantine/core"; -import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react"; +import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmMenuItem } from "@homarr/ui"; import type { ContainerSectionItem } from "~/app/[locale]/boards/_types"; -import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item"; import { useOpenItemMoveModal } from "../../items/item-move-modal"; import { useSectionContext } from "../section-context"; import { useContainerActions } from "./container-actions"; @@ -59,11 +59,14 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem {tItem("action.moveResize")} </Menu.Item> <Menu.Divider /> - <BoardRemoveConfirmationMenuItem - label={tContainer("action.remove")} - confirmationLabel={tContainer("remove.message")} + <InlineConfirmMenuItem + color="red" + confirmLabel={tContainer("remove.message")} + leftSection={<IconTrash size={16} />} onConfirm={() => removeContainer({ id: section.id })} - /> + > + {tContainer("action.remove")} + </InlineConfirmMenuItem> </Menu.Dropdown> </Menu> ); diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx index fc4642641..a7401f0e9 100644 --- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx +++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx @@ -18,7 +18,7 @@ import { getReadonlyCanvasAttributes, normalizeGridPlacement, } from "~/components/board/layout"; -import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; +import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas"; import { useGridEditorRuntimeStatus } from "./grid-editor-runtime"; import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry"; import type { SectionGridPlacement } from "./use-grid-layout-actions"; @@ -138,9 +138,56 @@ export const SectionGrid = ({ // of expanding to fit every widget, so its viewport height is capped independently of rowCount. const isScrollableContainer = section.kind === "container" && section.options.scrollable; const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount; - const logicalWidth = getLogicalGridSize(columnCount); - const logicalHeight = getLogicalGridSize(rowCount); - const viewportHeight = getLogicalGridSize(viewportRowCount); + // A container's own visible card is inset from its allocated board cell by the board's + // standard per-item gap (see the base, non-container `.staticItem[data-type="item"] > + // .contentMount` rule in section-grid.module.css - a container is placed as an "item" like + // any widget, so it gets that same gap). A widget's content just fills whatever size its + // card ends up being, but this SectionGrid instead computes its own fixed pixel size from + // the same column/row counts used to allocate the *outer*, uninset cell - so without + // subtracting that gap back out here, a container's inner grid renders larger than its own + // card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset. + const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1; + const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0; + const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset; + const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset; + // Items are positioned in a coordinate space sized to the *un-inset* column/row count + // (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are + // deliberately smaller by outerCardInset to match the container's actual visible card size. + // Left alone, that mismatch means the items - not just the grid's own box - spill past the + // card's right/bottom edge by exactly that inset. Scale the grid's content down by the same + // ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one + // level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is + // corrected to compensate so icon/text sizing inside the container isn't affected. + const fullGridWidth = getLogicalGridSize(columnCount); + const fullGridHeight = getLogicalGridSize(rowCount); + // For a scrollable container, rowCount covers *all* content rows, not just the visible card - + // fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content + // grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible + // viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount + // describe the real visible card size in both cases (they equal the non-scrollable values when + // rowCount and viewportRowCount are the same), so use those instead. + const fullViewportHeight = getLogicalGridSize(viewportRowCount); + // A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale + // is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies + // by, so a non-uniform stretch can never be captured by it correctly. That means one axis can + // be left with a small amount of unfilled slack when width/height need different ratios to + // exactly fit - centered below so it's even on both sides rather than left-aligned. + // logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container + // on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can + // exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach + // zero or negative - combinedUiScale divides by this value, and this value is also used as + // zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise. + const containerContentScale = + section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0 + ? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1)) + : 1; + // Compute the combined value in JS rather than a CSS calc() referencing the existing + // --board-canvas-ui-scale: custom properties declared on the *same* element don't have a + // sequential/temporal order the way normal variables do, so any calc() on this element that + // both reads and writes --board-canvas-ui-scale (even indirectly, through another property) + // is a circular reference - CSS invalidates the whole group rather than using "the old value", + // silently breaking every icon/text/custom-CSS size that compensates off it for descendants. + const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale; // A collapsed container's compact coordinates are display-only. Its own // nested grid stays inactive until an explicit edit interaction expands it. const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id); @@ -249,7 +296,15 @@ export const SectionGrid = ({ > <Box className={classes.staticGrid} - style={{ width: logicalWidth, height: logicalHeight }} + style={ + { + width: fullGridWidth, + height: fullGridHeight, + zoom: containerContentScale, + margin: containerContentScale < 1 ? "0 auto" : undefined, + ...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}), + } as CSSProperties + } data-grid-section-id={section.id} data-kind={section.kind} data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined} diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx index 4f268039e..b35d4f686 100644 --- a/apps/nextjs/src/components/layout/header.tsx +++ b/apps/nextjs/src/components/layout/header.tsx @@ -27,11 +27,12 @@ export const MainHeader = async ({ const session = await auth(); const isAdmin = Boolean(session?.user.permissions.includes("admin")); const isDockerEnabled = isAdmin && env.ENABLE_DOCKER; + const resolvedLogoWithTitle = logoWithTitle ?? <HomarrLogoWithTitle size="md" />; return ( <ConfigurableHeader logo={logo ?? <HomarrLogo size={appShellLogoHeight} />} - logoWithTitle={logoWithTitle ?? <HomarrLogoWithTitle size="md" />} + logoWithTitle={resolvedLogoWithTitle} actions={actions} boardEditAction={boardEditAction} boardSettingsAction={boardSettingsAction} diff --git a/apps/nextjs/src/components/layout/header/configurable-header.module.css b/apps/nextjs/src/components/layout/header/configurable-header.module.css index 2b851745d..698943cb6 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.module.css +++ b/apps/nextjs/src/components/layout/header/configurable-header.module.css @@ -2,6 +2,15 @@ overflow: hidden; } +.header[data-desktop-visible="false"] { + display: none; +} + +.desktopContent { + width: 100%; + height: 100%; +} + .content { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -64,6 +73,41 @@ flex: 0 0 auto; } +.mobileContent { + display: none; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-xs); + width: 100%; + height: 100%; + padding-inline: var(--mantine-spacing-xs); +} + +.mobileIdentity, +.mobileActions { + min-width: 0; +} + +.mobileIdentity { + flex: 1 1 auto; + overflow: hidden; +} + +.mobileIdentity > :last-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.mobileIdentity h2 { + overflow: hidden; + text-overflow: ellipsis; +} + +.mobileActions { + flex: 0 0 auto; +} + .floatingControls { position: fixed; z-index: var(--homarr-z-index-board-header); @@ -111,18 +155,21 @@ } @media (max-width: $mantine-breakpoint-sm) { - .content { - gap: var(--mantine-spacing-xs); - padding-inline: var(--mantine-spacing-xs); + .header[data-desktop-visible="false"] { + display: block; } - .zone { - gap: var(--mantine-spacing-xs); + .desktopContent { + display: none; } - .search { - width: auto; - flex: 0 0 auto; + .mobileContent { + display: flex; + } + + .floatingControls, + .floatingControlsCorner { + display: none; } } diff --git a/apps/nextjs/src/components/layout/header/configurable-header.tsx b/apps/nextjs/src/components/layout/header/configurable-header.tsx index 5d4bf0ef3..cdbd10f3f 100644 --- a/apps/nextjs/src/components/layout/header/configurable-header.tsx +++ b/apps/nextjs/src/components/layout/header/configurable-header.tsx @@ -2,15 +2,7 @@ import type { ReactNode } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; -import { - AppShellHeader, - Avatar, - Group, - Indicator, - Loader, - Tooltip, - useMantineColorScheme, -} from "@mantine/core"; +import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core"; import { IconBrandDocker, IconHome, @@ -116,14 +108,15 @@ export const ConfigurableHeader = ({ return ( <> - {headerPreferences.visible ? ( - <AppShellHeader - maw="100vw" - zIndex="var(--homarr-z-index-board-header)" - className={classes.header} - data-advanced-focus-background - data-app-shell-header - > + <AppShellHeader + maw="100vw" + zIndex="var(--homarr-z-index-board-header)" + className={classes.header} + data-desktop-visible={headerPreferences.visible} + data-advanced-focus-background + data-app-shell-header + > + <div className={classes.desktopContent}> <div className={classes.content}> <div className={classes.zone} data-zone="left"> {hasNavigation ? <ClientBurger /> : null} @@ -137,8 +130,31 @@ export const ConfigurableHeader = ({ {actions ? <Group className={classes.contextActions}>{actions}</Group> : null} </div> </div> - </AppShellHeader> - ) : ( + </div> + <div className={classes.mobileContent}> + <Group className={classes.mobileIdentity} gap="xs" wrap="nowrap"> + {hasNavigation ? <ClientBurger /> : null} + <HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} /> + </Group> + <Group className={classes.mobileActions} gap="xs" wrap="nowrap"> + {actions} + {boardEditAction} + {boardSettingsAction} + <TourTarget id="board-search"> + <MobileSearchButton alwaysVisible /> + </TourTarget> + <TourTarget id="board-user-menu"> + <UserButtonClient + avatar={avatar} + isAdmin={isAdmin} + isDockerEnabled={isDockerEnabled} + boardSwitcher={boardSwitcher} + /> + </TourTarget> + </Group> + </div> + </AppShellHeader> + {!headerPreferences.visible ? ( <FloatingHeaderControls> {hasNavigation ? <ClientBurger /> : null} <UserButtonClient @@ -148,7 +164,7 @@ export const ConfigurableHeader = ({ boardSwitcher={boardSwitcher} /> </FloatingHeaderControls> - )} + ) : null} <LazySpotlight /> </> ); @@ -291,7 +307,7 @@ const HeaderItem = ({ <Tooltip label={board.name}> <HeaderButton href={`/boards/${encodeURIComponent(board.name)}`} aria-label={board.name}> <Avatar src={board.logoImageUrl} size={22} radius="sm"> - <IconLayoutDashboard size={17} stroke={1.5} /> + {getBoardInitial(board.name)} </Avatar> </HeaderButton> </Tooltip> @@ -299,14 +315,7 @@ const HeaderItem = ({ } if (item.id === "logo") { - return ( - <HeaderLogo - display={logoDisplay} - logo={logo} - logoWithTitle={logoWithTitle} - label={label("logo")} - /> - ); + return <HeaderLogo display={logoDisplay} logo={logo} logoWithTitle={logoWithTitle} label={label("logo")} />; } if (item.id === "boardEdit") return boardEditAction; @@ -416,3 +425,5 @@ const HeaderItem = ({ </TourTarget> ); }; + +const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; diff --git a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx index 427988c74..f36f3959c 100644 --- a/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx +++ b/apps/nextjs/src/components/layout/header/lazy-spotlight.tsx @@ -54,11 +54,13 @@ export const LazySpotlight = () => { }; }, []); - return isMounted || isPreloaded ? ( + if (!isMounted && !isPreloaded) return null; + + return ( <> {isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />} {isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />} - <Spotlight /> + {isMounted && <Spotlight />} </> - ) : null; + ); }; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index 5f14a7da5..40a3c99f9 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -22,12 +22,12 @@ export const ClientShell = ({ const collapsed = useAtomValue(navigationCollapsedAtom); const backgroundProps = useOptionalBackgroundProps(); const { headerPreferences } = useSettings(); - const isHeaderVisible = hasHeader && headerPreferences.visible; + const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 }; return ( <AppShell {...backgroundProps} - header={isHeaderVisible ? { height: appShellHeaderHeight } : undefined} + header={hasHeader ? { height: headerHeight } : undefined} navbar={ hasNavigation ? { diff --git a/apps/nextjs/src/components/onboarding/manage-tour.tsx b/apps/nextjs/src/components/onboarding/manage-tour.tsx index ef3b38f7f..d81ccecb1 100644 --- a/apps/nextjs/src/components/onboarding/manage-tour.tsx +++ b/apps/nextjs/src/components/onboarding/manage-tour.tsx @@ -4,7 +4,6 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; -import { useMediaQuery } from "@mantine/hooks"; import { IconAffiliateFilled, IconAppsFilled, @@ -16,6 +15,7 @@ import { import { clientApi } from "@homarr/api/client"; import { createDocumentationLink } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; +import { useIsMobile } from "@homarr/ui/hooks"; import { TourTargetsProvider } from "~/components/layout/header/tour-target"; import { TourShell } from "./tour-shell"; @@ -48,7 +48,7 @@ const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = { export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => { const t = useI18n("onboardingTour.manage"); const { mutate: completeTour } = clientApi.user.completeTour.useMutation(); - const isMobile = useMediaQuery("(max-width: 48em)"); + const isMobile = useIsMobile(); const pathname = usePathname(); const router = useRouter(); diff --git a/apps/nextjs/src/components/workshop/workshop-report-form.tsx b/apps/nextjs/src/components/workshop/workshop-report-form.tsx new file mode 100644 index 000000000..e770d0b7c --- /dev/null +++ b/apps/nextjs/src/components/workshop/workshop-report-form.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { Alert, Box, Button, CloseButton, Collapse, Group, Paper, Select, Stack, Text, Textarea } from "@mantine/core"; + +import { showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import type { WorkshopBackend } from "@homarr/workshop/backend"; +import { useWorkshopReportMutation } from "@homarr/workshop/backend"; +import type { WorkshopReport } from "@homarr/workshop/schema"; + +const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const; + +interface WorkshopReportFormProps { + client: WorkshopBackend; + submissionId: string; + opened: boolean; + onClose(): void; +} + +export function WorkshopReportForm({ client, submissionId, opened, onClose }: WorkshopReportFormProps) { + const t = useI18n("workshop"); + const tCommon = useI18n("common"); + const [category, setCategory] = useState<WorkshopReport["category"]>("other"); + const [explanation, setExplanation] = useState(""); + const report = useWorkshopReportMutation(client); + + const close = () => { + setCategory("other"); + setExplanation(""); + report.reset(); + onClose(); + }; + + const submit = () => + report.mutate( + { submission: submissionId, category, explanation }, + { + onSuccess: () => { + showSuccessNotification({ + title: t("reportSent"), + message: t("reportSentDescription"), + }); + close(); + }, + }, + ); + + return ( + <Collapse id="workshop-report-form" expanded={opened}> + <Paper p="md"> + <form + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + <Stack> + <Group justify="space-between" align="flex-start" wrap="nowrap"> + <Box> + <Text fw={600}>{t("reportTitle")}</Text> + <Text size="sm" c="dimmed"> + {t("reportExplanation")} + </Text> + </Box> + <CloseButton aria-label={tCommon("action.close")} onClick={close} /> + </Group> + <Select + label={t("reportReason")} + value={category} + onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")} + data={categories.map((value) => ({ + value, + label: t(`reportCategory.${value}`), + }))} + allowDeselect={false} + /> + <Textarea + label={t("reportExplanation")} + minRows={4} + autosize + value={explanation} + onChange={(event) => setExplanation(event.currentTarget.value)} + /> + {report.error && <Alert color="red">{report.error.message}</Alert>} + <Group justify="flex-end" gap="sm"> + <Button variant="default" onClick={close}> + {tCommon("action.cancel")} + </Button> + <Button type="submit" color="red" loading={report.isPending} disabled={explanation.trim().length < 3}> + {t("reportSend")} + </Button> + </Group> + </Stack> + </form> + </Paper> + </Collapse> + ); +} diff --git a/apps/nextjs/src/components/workshop/workshop-report-modal.tsx b/apps/nextjs/src/components/workshop/workshop-report-modal.tsx deleted file mode 100644 index 89d447571..000000000 --- a/apps/nextjs/src/components/workshop/workshop-report-modal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core"; - -import { showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import type { WorkshopBackend } from "@homarr/workshop/backend"; -import { useWorkshopReportMutation } from "@homarr/workshop/backend"; -import type { WorkshopReport } from "@homarr/workshop/schema"; - -const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const; - -interface WorkshopReportModalProps { - client: WorkshopBackend; - submissionId: string; - opened: boolean; - onClose(): void; -} - -export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) { - const t = useI18n("workshop"); - const tCommon = useI18n("common"); - const [category, setCategory] = useState<WorkshopReport["category"]>("other"); - const [explanation, setExplanation] = useState(""); - const report = useWorkshopReportMutation(client); - - const close = () => { - setExplanation(""); - report.reset(); - onClose(); - }; - - const submit = () => - report.mutate( - { submission: submissionId, category, explanation }, - { - onSuccess: () => { - showSuccessNotification({ - title: t("reportSent"), - message: t("reportSentDescription"), - }); - close(); - }, - }, - ); - - return ( - <Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md"> - <Stack> - <Select - label={t("reportReason")} - value={category} - onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")} - data={categories.map((value) => ({ - value, - label: t(`reportCategory.${value}`), - }))} - allowDeselect={false} - /> - <Textarea - label={t("reportExplanation")} - minRows={4} - autosize - value={explanation} - onChange={(event) => setExplanation(event.currentTarget.value)} - /> - {report.error && <Alert color="red">{report.error.message}</Alert>} - <Group justify="flex-end" gap="sm"> - <Button variant="default" onClick={close}> - {tCommon("action.cancel")} - </Button> - <Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}> - {t("reportSend")} - </Button> - </Group> - </Stack> - </Modal> - ); -} diff --git a/package.json b/package.json index 347e76aa0..477724057 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint": "turbo lint --continue", "lint:fix": "turbo lint --continue -- --fix", "lint:ws": "pnpm dlx sherif@latest", + "qa:browser-agent:ux": "bash scripts/browser-agent/ux-coherence-happy-paths.sh", "release": "semantic-release", "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index a1cb201ea..3ef88895b 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -1,5 +1,16 @@ import type { QueryKey } from "@tanstack/react-query"; +const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null; + +interface ForbiddenTrpcError { + data: { code: "FORBIDDEN" }; +} + +export const isTrpcForbiddenError = (error: unknown): error is ForbiddenTrpcError => { + if (!isRecord(error) || !isRecord(error.data)) return false; + return error.data.code === "FORBIDDEN"; +}; + // RSC-prefetched data must survive the hydration pass without an immediate // duplicate fetch. Widget-specific intervals still control live refreshes. export const queryCacheDefaultStaleTimeMs = 30_000; @@ -8,6 +19,7 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5; export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); +const persistenceExcludedWidgetPaths = new Set(["widget.customApi.getData", "widget.customApi.queryRequest"]); // Beszel system stats are large time-series payloads and were deliberately kept out of // the persisted cache in #6289; live stats come from an SSE subscription instead. const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); @@ -66,3 +78,11 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => { return isWidgetDataTrpcPath(path.join(".")); }; + +export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => { + const path = getTrpcPathFromQueryKey(queryKey); + if (!path) return false; + + const joinedPath = path.join("."); + return isWidgetDataTrpcPath(joinedPath) && !persistenceExcludedWidgetPaths.has(joinedPath); +}; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 75a0df9da..e8aae1b60 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -84,9 +84,14 @@ interface BoardItemPlacementRectangle { } const boardItemPlacementTails = new Map<string, Promise<void>>(); -const maxManageOverviewPreviewRows = 12; +const defaultManageOverviewPreviewRows = 12; +const maxManageOverviewPreviewRows = 48; const manageOverviewInputSchema = z - .object({ fullPreview: z.boolean().default(false), userId: z.string().optional() }) + .object({ + fullPreview: z.boolean().default(false), + previewRowLimit: z.number().int().min(1).max(maxManageOverviewPreviewRows).optional(), + userId: z.string().optional(), + }) .optional(); const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => { @@ -374,6 +379,8 @@ export const boardRouter = createTRPCRouter({ const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); const canViewAllBoards = getCanViewAllBoards(groupMemberships); + let previewRowLimit = input?.previewRowLimit ?? defaultManageOverviewPreviewRows; + if (input?.fullPreview) previewRowLimit = maxManageOverviewPreviewRows; const dbBoards = await ctx.db.query.boards.findMany({ columns: { @@ -451,7 +458,7 @@ export const boardRouter = createTRPCRouter({ inArray(itemLayouts.sectionId, rootSectionIds), gte(itemLayouts.xOffset, 0), gte(itemLayouts.yOffset, 0), - input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + lt(itemLayouts.yOffset, previewRowLimit), gt(itemLayouts.width, 0), gt(itemLayouts.height, 0), ), @@ -476,7 +483,7 @@ export const boardRouter = createTRPCRouter({ inArray(sectionLayouts.parentSectionId, rootSectionIds), gte(sectionLayouts.xOffset, 0), gte(sectionLayouts.yOffset, 0), - input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows), + lt(sectionLayouts.yOffset, previewRowLimit), gt(sectionLayouts.width, 0), gt(sectionLayouts.height, 0), ), @@ -511,7 +518,7 @@ export const boardRouter = createTRPCRouter({ inArray(itemLayouts.sectionId, previewContainerSectionIds), gte(itemLayouts.xOffset, 0), gte(itemLayouts.yOffset, 0), - input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + lt(itemLayouts.yOffset, previewRowLimit), gt(itemLayouts.width, 0), gt(itemLayouts.height, 0), ), diff --git a/packages/api/src/router/docker/docker-reconciliation.ts b/packages/api/src/router/docker/docker-reconciliation.ts index de3131f3d..03ff50f38 100644 --- a/packages/api/src/router/docker/docker-reconciliation.ts +++ b/packages/api/src/router/docker/docker-reconciliation.ts @@ -84,15 +84,13 @@ export const getDockerReconciliationAsync = async (db: Database) => { const appAmbiguous = !linkedApp && (exactAppMatches.length > 1 || (exactAppMatches.length === 0 && nameAppMatches.length > 1)); const ambiguous = integrationAmbiguous || appAmbiguous; - const state: DockerReconciliationState = integrationMoved - ? "moved" - : linked - ? "linked" - : integration || app - ? "represented" - : match - ? "newRecognized" - : "newApp"; + const state = getReconciliationState({ + integrationMoved, + linked, + represented: Boolean(integration || app), + recognized: Boolean(match), + }); + const nextAction = getCandidateNextAction(state, integrationAmbiguous, appAmbiguous); return { candidateKey: container.resourceId, @@ -103,11 +101,7 @@ export const getDockerReconciliationAsync = async (db: Database) => { match, urlCandidates, state, - nextAction: integrationAmbiguous - ? ("reviewIntegration" as const) - : appAmbiguous - ? ("viewRepresentation" as const) - : nextActionForState(state), + nextAction, representation: { integration: integration ? { @@ -147,87 +141,100 @@ export const getDockerServiceHealthAsync = async (db: Database) => { status: endpoint.status, nextAction: endpoint.status === "unavailable" ? ("checkEndpoint" as const) : ("none" as const), })), - services: reconciliation.candidates.map((candidate) => ({ - key: candidate.candidateKey, - identity: { - endpointId: candidate.endpointId, - nativeId: candidate.nativeId, - name: candidate.container.name, - integration: candidate.representation.integration, - app: candidate.representation.app, - }, - layers: [ - { layer: "docker" as const, status: "available" as const, nextAction: "none" as const }, - { - layer: "integrationConfiguration" as const, - status: integrationHealthStatus( - candidate.state, - candidate.match?.kind, - Boolean(candidate.representation.integration), - ), - nextAction: - candidate.nextAction === "reviewIntegration" - ? ("reviewIntegration" as const) - : candidate.state === "newRecognized" - ? ("setupIntegration" as const) - : ("none" as const), + services: reconciliation.candidates.map((candidate) => { + const integration = candidate.representation.integration; + const app = candidate.representation.app; + const itemCount = integration?.itemCount; + + return { + key: candidate.candidateKey, + identity: { + endpointId: candidate.endpointId, + nativeId: candidate.nativeId, + name: candidate.container.name, + integration, + app, }, - { - layer: "authentication" as const, - status: "notObserved" as const, - nextAction: candidate.representation.integration - ? ("testConnection" as const) - : candidate.nextAction === "reviewIntegration" - ? ("reviewIntegration" as const) - : candidate.match - ? ("setupIntegration" as const) - : ("none" as const), - }, - { - layer: "apiRequest" as const, - status: "notObserved" as const, - nextAction: candidate.representation.integration - ? ("openIntegrationDiagnostics" as const) - : ("none" as const), - }, - { - layer: "appRepresentation" as const, - status: candidate.representation.app ? ("linked" as const) : ("missing" as const), - nextAction: candidate.representation.app - ? ("none" as const) - : candidate.representation.signals.ambiguous && candidate.nextAction === "viewRepresentation" - ? ("viewRepresentation" as const) - : ("createApp" as const), - }, - { - layer: "widgetConfiguration" as const, - status: candidate.representation.integration - ? candidate.representation.integration.itemCount > 0 - ? ("linked" as const) - : ("unused" as const) - : ("notApplicable" as const), - nextAction: - candidate.representation.integration && candidate.representation.integration.itemCount === 0 - ? ("addWidget" as const) - : ("none" as const), - }, - { - layer: "widgetQuery" as const, - status: "notObserved" as const, - nextAction: - candidate.representation.integration && candidate.representation.integration.itemCount > 0 - ? ("openBoard" as const) - : ("none" as const), - }, - ], - status: candidate.state, - nextAction: candidate.nextAction, - })), + layers: [ + { layer: "docker" as const, status: "available" as const, nextAction: "none" as const }, + { + layer: "integrationConfiguration" as const, + status: integrationHealthStatus(candidate.state, candidate.match?.kind, Boolean(integration)), + nextAction: getIntegrationConfigurationNextAction(candidate.state, candidate.nextAction), + }, + { + layer: "authentication" as const, + status: "notObserved" as const, + nextAction: getAuthenticationNextAction( + Boolean(integration), + Boolean(candidate.match), + candidate.nextAction, + ), + }, + { + layer: "apiRequest" as const, + status: "notObserved" as const, + nextAction: integration ? ("openIntegrationDiagnostics" as const) : ("none" as const), + }, + { + layer: "appRepresentation" as const, + status: app ? ("linked" as const) : ("missing" as const), + nextAction: getAppRepresentationNextAction( + Boolean(app), + candidate.representation.signals.ambiguous, + candidate.nextAction, + ), + }, + { + layer: "widgetConfiguration" as const, + status: getWidgetConfigurationStatus(itemCount), + nextAction: itemCount === 0 ? ("addWidget" as const) : ("none" as const), + }, + { + layer: "widgetQuery" as const, + status: "notObserved" as const, + nextAction: itemCount !== undefined && itemCount > 0 ? ("openBoard" as const) : ("none" as const), + }, + ], + status: candidate.state, + nextAction: candidate.nextAction, + }; + }), }; }; const normalizeName = (value: string) => value.trim().toLowerCase(); +interface ReconciliationStateInput { + integrationMoved: boolean; + linked: boolean; + represented: boolean; + recognized: boolean; +} + +const getReconciliationState = ({ + integrationMoved, + linked, + represented, + recognized, +}: ReconciliationStateInput): DockerReconciliationState => { + if (integrationMoved) return "moved"; + if (linked) return "linked"; + if (represented) return "represented"; + if (recognized) return "newRecognized"; + return "newApp"; +}; + +const getCandidateNextAction = ( + state: DockerReconciliationState, + integrationAmbiguous: boolean, + appAmbiguous: boolean, +) => { + if (integrationAmbiguous) return "reviewIntegration" as const; + if (appAmbiguous) return "viewRepresentation" as const; + return nextActionForState(state); +}; + const nextActionForState = (state: DockerReconciliationState): DockerReconciliationNextAction => { if (state === "newRecognized") return "setupIntegration"; if (state === "newApp") return "createApp"; @@ -235,6 +242,42 @@ const nextActionForState = (state: DockerReconciliationState): DockerReconciliat return "viewRepresentation"; }; +const getIntegrationConfigurationNextAction = ( + state: DockerReconciliationState, + nextAction: DockerReconciliationNextAction, +) => { + if (nextAction === "reviewIntegration") return "reviewIntegration" as const; + if (state === "newRecognized") return "setupIntegration" as const; + return "none" as const; +}; + +const getAuthenticationNextAction = ( + hasIntegration: boolean, + hasMatch: boolean, + nextAction: DockerReconciliationNextAction, +) => { + if (hasIntegration) return "testConnection" as const; + if (nextAction === "reviewIntegration") return "reviewIntegration" as const; + if (hasMatch) return "setupIntegration" as const; + return "none" as const; +}; + +const getAppRepresentationNextAction = ( + hasApp: boolean, + ambiguous: boolean, + nextAction: DockerReconciliationNextAction, +) => { + if (hasApp) return "none" as const; + if (ambiguous && nextAction === "viewRepresentation") return "viewRepresentation" as const; + return "createApp" as const; +}; + +const getWidgetConfigurationStatus = (itemCount: number | undefined) => { + if (itemCount === undefined) return "notApplicable" as const; + if (itemCount > 0) return "linked" as const; + return "unused" as const; +}; + const integrationHealthStatus = ( state: DockerReconciliationState, kind: IntegrationKind | undefined, @@ -242,5 +285,6 @@ const integrationHealthStatus = ( ) => { if (!kind) return "notApplicable" as const; if (state === "moved") return "changed" as const; - return hasIntegration ? ("configured" as const) : ("missing" as const); + if (hasIntegration) return "configured" as const; + return "missing" as const; }; diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index d9cc04695..504a8c068 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -14,6 +14,7 @@ import { dockerRouter } from "../../docker/docker-router"; const findDockerContainerAsyncMock = vi.hoisted(() => vi.fn()); const hasDockerEndpointCapabilityMock = vi.hoisted(() => vi.fn(() => true)); const invalidateDockerCacheMock = vi.hoisted(() => vi.fn()); +const resetDockerSingletonMock = vi.hoisted(() => vi.fn()); const getDockerDataAsyncMock = vi.hoisted(() => vi.fn< () => Promise<{ @@ -62,7 +63,7 @@ vi.mock("@homarr/docker", () => ({ }, }, ], - reset: () => undefined, + reset: resetDockerSingletonMock, }, })); vi.mock("@homarr/request-handler/docker", () => ({ @@ -501,6 +502,7 @@ test("invalidates the shared Docker inventory on explicit refresh", async () => await createAdminCaller().refreshInventory(); expect(invalidateDockerCacheMock).toHaveBeenCalledOnce(); + expect(resetDockerSingletonMock).toHaveBeenCalledOnce(); }); test("projects persisted service layers without inventing runtime health", async () => { diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts index 01a92bb59..09f582dfa 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -47,7 +47,10 @@ export const immichRouter = createTRPCRouter({ ) .concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel")) .query(async ({ ctx, input }) => { - if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit); + if (ctx.integration.kind === "mock") { + if (input.limit === undefined) return [...mockWidgetData.immichAlbums]; + return mockWidgetData.immichAlbums.slice(0, input.limit); + } const innerHandler = immichAlbumsRequestHandler.handler( { ...ctx.integration, kind: "immich" }, { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1aa483261..717c71832 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,7 +6,8 @@ import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness const harnessCommandNames = new Set(["harness", "local-harness"]); function isHarnessInvocation(argv: string[]): boolean { - return argv.slice(2).some((argument) => harnessCommandNames.has(argument)); + const command = argv[2]; + return command !== undefined && harnessCommandNames.has(command); } async function main(): Promise<void> { diff --git a/packages/modals-collection/src/boards/add-board-modal.tsx b/packages/forms-collection/src/board-create/board-create-form.tsx similarity index 61% rename from packages/modals-collection/src/boards/add-board-modal.tsx rename to packages/forms-collection/src/board-create/board-create-form.tsx index cf2a4848a..e96a13597 100644 --- a/packages/modals-collection/src/boards/add-board-modal.tsx +++ b/packages/forms-collection/src/board-create/board-create-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useRouter } from "next/navigation"; import { Button, Group, InputWrapper, Slider, Stack, Switch, TextInput } from "@mantine/core"; import { useDebouncedValue } from "@mantine/hooks"; @@ -6,12 +8,16 @@ import { IconAlertTriangle, IconCircleCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; import { boardColumnCountSchema, boardCreateSchema, boardNameSchema } from "@homarr/validation/board"; -export const AddBoardModal = createModal(({ actions }) => { +interface BoardCreateFormProps { + onCancel?: () => void; +} + +export const BoardCreateForm = ({ onCancel }: BoardCreateFormProps) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const router = useRouter(); @@ -38,7 +44,6 @@ export const AddBoardModal = createModal(({ actions }) => { if (!boardNameStatus.canSubmit) return; mutate(values, { onSuccess: (result) => { - actions.closeModal(); showSuccessNotification({ title: tBoard("action.create.notification.success.title"), message: tBoard("action.create.notification.success.message", { name: values.name }), @@ -56,24 +61,17 @@ export const AddBoardModal = createModal(({ actions }) => { }); })} > - <Stack> + <Stack maw={720}> <TextInput label={tCommon("field.name")} - data-autofocus + withAsterisk + autoFocus {...form.getInputProps("name")} - description={ - boardNameStatus.description ? ( - <span> - <Group c={boardNameStatus.description.color} gap="xs" align="center"> - {boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null} - <span>{boardNameStatus.description.label}</span> - </Group> - </span> - ) : null - } + description={<BoardNameAvailability status={boardNameStatus} />} /> <InputWrapper label={tBoard("field.columnCount.label")} {...form.getInputProps("columnCount")}> <Slider + aria-label={tBoard("field.columnCount.label")} min={boardColumnCountSchema.minValue ?? undefined} max={boardColumnCountSchema.maxValue ?? undefined} step={1} @@ -87,50 +85,71 @@ export const AddBoardModal = createModal(({ actions }) => { {...form.getInputProps("isPublic")} /> - <Group justify="right"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {tCommon("action.cancel")} - </Button> - <Button type="submit" loading={isPending}> + <Group justify="end" wrap="wrap"> + {onCancel && ( + <Button variant="default" onClick={onCancel}> + {tCommon("action.cancel")} + </Button> + )} + <Button type="submit" loading={isPending} disabled={!boardNameStatus.canSubmit}> {tCommon("action.create")} </Button> </Group> </Stack> </form> ); -}).withOptions({ - defaultTitle: (t) => t("management.page.board.action.new.label"), -}); +}; export const useBoardNameStatus = (name: string) => { const tBoard = useI18n("board"); const tCommon = useI18n("common"); const [debouncedName] = useDebouncedValue(name, 250); + const isValidName = boardNameSchema.safeParse(name).success; + const isDebouncing = name !== debouncedName; const { data: boardExists, isLoading } = clientApi.board.exists.useQuery(debouncedName, { enabled: boardNameSchema.safeParse(debouncedName).success, }); + let description: { label: string; icon?: TablerIcon; color?: string } | undefined; + + if (debouncedName.trim() !== "") { + if (isDebouncing || isLoading) { + description = { + label: tBoard("action.create.availability.checking"), + }; + } else if (boardExists !== undefined) { + if (boardExists) { + description = { + icon: IconAlertTriangle, + label: tCommon("zod.errors.custom.boardAlreadyExists"), + color: "red", + }; + } else { + description = { + icon: IconCircleCheck, + label: tBoard("action.create.availability.available", { name: debouncedName }), + color: "green", + }; + } + } + } return { - canSubmit: !boardExists && !isLoading, - description: - debouncedName.trim() === "" - ? undefined - : isLoading - ? { - label: tBoard("action.create.availability.checking"), - } - : boardExists === undefined - ? undefined - : boardExists - ? { - icon: IconAlertTriangle, - label: tCommon("zod.errors.custom.boardAlreadyExists"), // The board ${debouncedName} already exists - color: "red", - } - : { - icon: IconCircleCheck, - label: tBoard("action.create.availability.available", { name: debouncedName }), - color: "green", - }, + canSubmit: isValidName && !isDebouncing && boardExists === false && !isLoading, + description, }; }; + +export type BoardNameStatus = ReturnType<typeof useBoardNameStatus>; + +export const BoardNameAvailability = ({ status }: { status: BoardNameStatus }) => { + if (!status.description) return null; + + const Icon = status.description.icon; + + return ( + <Group component="output" c={status.description.color} gap="xs" align="center" aria-live="polite"> + {Icon && <Icon size={16} />} + <span>{status.description.label}</span> + </Group> + ); +}; diff --git a/packages/forms-collection/src/index.tsx b/packages/forms-collection/src/index.tsx index 0dfaf90ca..6253a1d2c 100644 --- a/packages/forms-collection/src/index.tsx +++ b/packages/forms-collection/src/index.tsx @@ -1,6 +1,8 @@ export * from "./new-app/_app-new-form"; export * from "./new-app/_form"; +export * from "./board-create/board-create-form"; + export * from "./icon-picker/icon-picker"; export * from "./upload-media/upload-media"; diff --git a/packages/modals-collection/src/boards/index.ts b/packages/modals-collection/src/boards/index.ts deleted file mode 100644 index ff591c8ac..000000000 --- a/packages/modals-collection/src/boards/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { AddBoardModal } from "./add-board-modal"; -export { DuplicateBoardModal } from "./duplicate-board-modal"; diff --git a/packages/modals-collection/src/certificates/add-certificate-modal.tsx b/packages/modals-collection/src/certificates/add-certificate-modal.tsx deleted file mode 100644 index b414d0c6a..000000000 --- a/packages/modals-collection/src/certificates/add-certificate-modal.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Button, FileInput, Group, Stack } from "@mantine/core"; -import { IconCertificate } from "@tabler/icons-react"; -import { z } from "zod/v4"; - -import { clientApi } from "@homarr/api/client"; -import type { MaybePromise } from "@homarr/common/types"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { checkCertificateFile } from "@homarr/validation/certificates"; - -interface InnerProps { - onSuccess?: () => MaybePromise<void>; -} - -export const AddCertificateModal = createModal<InnerProps>(({ actions, innerProps }) => { - const t = useI18n(); - const form = useZodForm( - z.object({ - file: z.file().check(checkCertificateFile), - }), - { - initialValues: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - file: null!, - }, - }, - ); - const { mutateAsync } = clientApi.certificates.addCertificate.useMutation({ - async onSuccess() { - await innerProps.onSuccess?.(); - }, - }); - - return ( - <form - onSubmit={form.onSubmit(async (values) => { - const formData = new FormData(); - formData.set("file", values.file); - await mutateAsync(formData, { - onSuccess() { - showSuccessNotification({ - title: t("certificate.action.create.notification.success.title"), - message: t("certificate.action.create.notification.success.message"), - }); - actions.closeModal(); - }, - onError() { - showErrorNotification({ - title: t("certificate.action.create.notification.error.title"), - message: t("certificate.action.create.notification.error.message"), - }); - }, - }); - })} - > - <Stack> - <FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} /> - <Group justify="end"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {t("common.action.cancel")} - </Button> - <Button type="submit" loading={form.submitting}> - {t("common.action.add")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("certificate.action.create.label"); - }, -}); diff --git a/packages/modals-collection/src/certificates/index.ts b/packages/modals-collection/src/certificates/index.ts deleted file mode 100644 index 0bdf423ea..000000000 --- a/packages/modals-collection/src/certificates/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./add-certificate-modal"; diff --git a/packages/modals-collection/src/groups/add-group-modal.tsx b/packages/modals-collection/src/groups/add-group-modal.tsx deleted file mode 100644 index 817ce0072..000000000 --- a/packages/modals-collection/src/groups/add-group-modal.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button, Group, Stack, TextInput } from "@mantine/core"; - -import { clientApi } from "@homarr/api/client"; -import { revalidatePathActionAsync } from "@homarr/common/client"; -import { useZodForm } from "@homarr/form"; -import { createModal } from "@homarr/modals"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n } from "@homarr/translation/client"; -import { groupCreateSchema } from "@homarr/validation/group"; - -export const AddGroupModal = createModal<void>(({ actions }) => { - const tGroup = useI18n("group"); - const tCommon = useI18n("common"); - const { mutate, isPending } = clientApi.group.createGroup.useMutation(); - const form = useZodForm(groupCreateSchema, { - initialValues: { - name: "", - }, - }); - - return ( - <form - onSubmit={form.onSubmit((values) => { - mutate(values, { - onSuccess() { - actions.closeModal(); - void revalidatePathActionAsync("/manage/users/groups"); - showSuccessNotification({ - title: tCommon("notification.create.success"), - message: tGroup("action.create.notification.success.message"), - }); - }, - onError() { - showErrorNotification({ - title: tCommon("notification.create.error"), - message: tGroup("action.create.notification.error.message"), - }); - }, - }); - })} - > - <Stack> - <TextInput label={tCommon("field.name")} data-autofocus {...form.getInputProps("name")} /> - <Group justify="right"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {tCommon("action.cancel")} - </Button> - <Button loading={isPending} type="submit"> - {tCommon("action.create")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle: (t) => t("group.action.create.label"), -}); diff --git a/packages/modals-collection/src/groups/index.ts b/packages/modals-collection/src/groups/index.ts deleted file mode 100644 index 428bea2ec..000000000 --- a/packages/modals-collection/src/groups/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AddGroupModal } from "./add-group-modal"; diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts index 02f9f3bee..6403ccd40 100644 --- a/packages/modals-collection/src/index.ts +++ b/packages/modals-collection/src/index.ts @@ -1,7 +1,3 @@ -export * from "./boards"; -export * from "./invites"; -export * from "./groups"; export * from "./search-engines"; export * from "./docker"; export * from "./apps"; -export * from "./certificates"; diff --git a/packages/modals-collection/src/invites/index.ts b/packages/modals-collection/src/invites/index.ts deleted file mode 100644 index f55df7d2b..000000000 --- a/packages/modals-collection/src/invites/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { InviteCopyModal } from "./invite-copy-modal"; -export { InviteCreateModal } from "./invite-create-modal"; diff --git a/packages/modals-collection/src/invites/invite-copy-modal.tsx b/packages/modals-collection/src/invites/invite-copy-modal.tsx deleted file mode 100644 index 199c0d74d..000000000 --- a/packages/modals-collection/src/invites/invite-copy-modal.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { usePathname } from "next/navigation"; -import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core"; - -import type { RouterOutputs } from "@homarr/api"; -import { invariantTechnicalLabels } from "@homarr/definitions"; -import { createModal } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; - -export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => { - const t = useI18n("management.page.user.invite"); - const inviteUrl = useInviteUrl(innerProps); - - return ( - <Stack> - <Text> - {t.rich("action.copy.description", { - b: (children) => <b>{children}</b>, - })} - </Text> - <Link href={createPath(innerProps)}>{t("action.copy.link")}</Link> - <Stack gap="xs"> - <Text fw="bold">{invariantTechnicalLabels.id}:</Text> - <Mark style={{ borderRadius: 4 }} color="gray" px={5}> - {innerProps.id} - </Mark> - - <Text fw="bold">{t("field.token.label")}:</Text> - <Mark style={{ borderRadius: 4 }} color="gray" px={5}> - {innerProps.token} - </Mark> - </Stack> - <CopyButton value={inviteUrl}> - {({ copy }) => ( - <Button - onClick={() => { - copy(); - actions.closeModal(); - }} - variant="default" - fullWidth - > - {t("action.copy.button")} - </Button> - )} - </CopyButton> - </Stack> - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.user.invite.action.copy.title"); - }, -}); - -const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`; - -const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => { - const pathname = usePathname(); - - return window.location.href.replace(pathname, createPath({ id, token })); -}; diff --git a/packages/modals-collection/src/invites/invite-create-modal.tsx b/packages/modals-collection/src/invites/invite-create-modal.tsx deleted file mode 100644 index 23958f017..000000000 --- a/packages/modals-collection/src/invites/invite-create-modal.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Button, Group, Stack, Text } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import dayjs from "dayjs"; -import relativeTime from "dayjs/plugin/relativeTime"; - -import { clientApi } from "@homarr/api/client"; -import { useForm } from "@homarr/form"; -import { createModal, useModalAction } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; - -import { InviteCopyModal } from "./invite-copy-modal"; - -dayjs.extend(relativeTime); - -interface FormType { - expirationDate: string; -} - -export const InviteCreateModal = createModal<void>(({ actions }) => { - const tInvite = useI18n("management.page.user.invite"); - const t = useI18n(); - const { openModal } = useModalAction(InviteCopyModal); - - const utils = clientApi.useUtils(); - const { mutate, isPending } = clientApi.invite.createInvite.useMutation(); - const minDate = dayjs().add(1, "hour").toDate(); - const maxDate = dayjs().add(6, "months").toDate(); - - const form = useForm<FormType>({ - initialValues: { - expirationDate: dayjs().add(4, "hours").toDate().toISOString(), - }, - }); - - const handleSubmit = (values: FormType) => { - mutate( - { - expirationDate: new Date(values.expirationDate), - }, - { - onSuccess: (result) => { - void utils.invite.getAll.invalidate(); - actions.closeModal(); - openModal(result); - }, - }, - ); - }; - - return ( - <form onSubmit={form.onSubmit(handleSubmit)}> - <Stack> - <Text>{tInvite("action.new.description")}</Text> - - <DateTimePicker - popoverProps={{ withinPortal: true }} - minDate={minDate} - maxDate={maxDate} - withAsterisk - valueFormat="DD MMM YYYY HH:mm" - label={tInvite("field.expirationDate.label")} - variant="filled" - {...form.getInputProps("expirationDate")} - /> - - <Group justify="end"> - <Button onClick={actions.closeModal} variant="subtle" color="gray"> - {t("common.action.cancel")} - </Button> - <Button type="submit" loading={isPending}> - {t("common.action.create")} - </Button> - </Group> - </Stack> - </form> - ); -}).withOptions({ - defaultTitle(t) { - return t("management.page.user.invite.action.new.title"); - }, -}); diff --git a/packages/spotlight/src/modes/command/global-group.tsx b/packages/spotlight/src/modes/command/global-group.tsx index 58622ef47..eb17047d7 100644 --- a/packages/spotlight/src/modes/command/global-group.tsx +++ b/packages/spotlight/src/modes/command/global-group.tsx @@ -12,8 +12,6 @@ import { import { useSession } from "@homarr/auth/client"; import type { GroupPermissionKey } from "@homarr/definitions"; -import { useModalAction } from "@homarr/modals"; -import { AddBoardModal, AddGroupModal, InviteCreateModal } from "@homarr/modals-collection"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; @@ -78,16 +76,7 @@ export const globalCommandGroup = createGroup<Command>({ commandKey: "newBoard", icon: IconCategoryPlus, name: tOption("newBoard.label"), - useInteraction() { - const { openModal } = useModalAction(AddBoardModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/boards?create=true" })), hidden: !session?.user.permissions.includes("board-create"), }, { @@ -115,32 +104,14 @@ export const globalCommandGroup = createGroup<Command>({ commandKey: "newInvite", icon: IconMailForward, name: tOption("newInvite.label"), - useInteraction() { - const { openModal } = useModalAction(InviteCreateModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/users/invites?create=true" })), hidden: !session?.user.permissions.includes("admin"), }, { commandKey: "newGroup", icon: IconUsersGroup, name: tOption("newGroup.label"), - useInteraction() { - const { openModal } = useModalAction(AddGroupModal); - - return { - type: "javaScript", - onSelect() { - openModal(undefined); - }, - }; - }, + useInteraction: interaction.link(() => ({ href: "/manage/users/groups?create=true" })), hidden: !session?.user.permissions.includes("admin"), }, ]; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 59e09e437..8ebdd3511 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1783,6 +1783,7 @@ "deleteNamed": "Delete {name}", "clearSearch": "Clear search", "copy": "Copy", + "copied": "Copied", "refresh": "Refresh" }, "here": "here", @@ -3179,7 +3180,8 @@ "disabled": "Disabled" }, "error": { - "internalServerError": "Failed to fetch indexers status" + "internalServerError": "Failed to fetch indexers status", + "testAll": "The indexer test failed. Try again." } }, "healthMonitoring": { @@ -4249,6 +4251,7 @@ "failed": "Failed", "completed": "Completed" }, + "unknown": "Unknown", "toBeDetermined": "TBD" }, "mediaRequests-requestStats": { @@ -4961,6 +4964,10 @@ "empty": "No alerts configured", "history": "History", "resolved": "Resolved", + "alertDescription": { + "statusDuration": "Down for {minutes} min", + "threshold": "Exceeds {value} over {minutes} min" + }, "status": { "triggered": "Triggered", "ok": "OK" diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 776f12837..99fc5378e 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -1,5 +1,6 @@ export * from "./count-badge"; export { OverflowBadge } from "./overflow-badge"; +export { InlineConfirmActionIcon, InlineConfirmButton, InlineConfirmMenuItem } from "./inline-confirm-action"; export { SearchInput } from "./search-input"; export * from "./select-with-description"; export * from "./select-with-description-and-badge"; diff --git a/packages/ui/src/components/inline-confirm-action.tsx b/packages/ui/src/components/inline-confirm-action.tsx new file mode 100644 index 000000000..ad355e4ad --- /dev/null +++ b/packages/ui/src/components/inline-confirm-action.tsx @@ -0,0 +1,239 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { ActionIcon, Button, Menu } from "@mantine/core"; +import type { ActionIconProps, ButtonProps, ElementProps, MenuItemProps } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +const confirmationTimeout = 4_000; + +const visuallyHidden = { + border: 0, + clip: "rect(0 0 0 0)", + height: 1, + margin: -1, + overflow: "hidden", + padding: 0, + position: "absolute" as const, + whiteSpace: "nowrap" as const, + width: 1, +}; + +interface InlineConfirmActionOptions { + confirmLabel: ReactNode; + onConfirm: () => void | Promise<void>; + disabled?: boolean; + pending?: boolean; + timeout?: number; +} + +const useInlineConfirmAction = ({ + confirmLabel, + onConfirm, + disabled = false, + pending = false, + timeout = confirmationTimeout, +}: InlineConfirmActionOptions) => { + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + const actionRef = useRef<HTMLButtonElement>(null); + const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + const reset = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setConfirming(false); + }, []); + + useEffect(() => { + if (!confirming) return; + + const handlePointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && !actionRef.current?.contains(event.target)) reset(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") reset(); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown, true); + timeoutRef.current = setTimeout(reset, timeout); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown, true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + }, [confirming, reset, timeout]); + + useEffect(() => { + if (disabled || pending) reset(); + }, [disabled, pending, reset]); + + const handleClick = useCallback(async () => { + if (disabled || pending || submitting) return; + if (!confirming) { + setConfirming(true); + return; + } + + setSubmitting(true); + try { + await onConfirm(); + } finally { + setSubmitting(false); + reset(); + } + }, [confirming, disabled, onConfirm, pending, reset, submitting]); + + const handleBlur = useCallback(() => reset(), [reset]); + + return { + actionRef, + confirming, + confirmLabel, + handleBlur, + handleClick, + reset, + submitting, + }; +}; + +type InlineConfirmButtonProps = Omit<ButtonProps, "children" | "disabled" | "loading" | "onClick"> & + ElementProps<"button", "color" | "disabled" | "onClick"> & + InlineConfirmActionOptions & { + children: ReactNode; + loading?: boolean; + }; + +export const InlineConfirmButton = ({ + children, + confirmLabel, + onConfirm, + disabled, + pending, + loading, + timeout, + variant, + ...props +}: InlineConfirmButtonProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout }); + const label = action.confirming ? confirmLabel : children; + let nextVariant = variant; + if (action.confirming) nextVariant = "filled"; + + return ( + <> + <Button + {...props} + ref={action.actionRef} + variant={nextVariant} + disabled={disabled || pending || action.submitting} + loading={loading || action.submitting} + onClick={() => void action.handleClick().catch(() => undefined)} + onBlur={action.handleBlur} + aria-label={typeof label === "string" ? label : undefined} + data-confirming={action.confirming || undefined} + > + {label} + </Button> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; + +type InlineConfirmActionIconProps = Omit<ActionIconProps, "children" | "disabled" | "loading" | "onClick"> & + InlineConfirmActionOptions & { + "aria-label"?: string; + children: ReactNode; + confirmationAriaLabel?: string; + confirmationChildren?: ReactNode; + loading?: boolean; + title?: string; + }; + +export const InlineConfirmActionIcon = ({ + children, + confirmLabel, + confirmationAriaLabel, + confirmationChildren, + onConfirm, + disabled, + pending, + loading, + timeout, + variant, + "aria-label": ariaLabel, + ...props +}: InlineConfirmActionIconProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending: pending || loading, timeout }); + const nextAriaLabel = action.confirming + ? (confirmationAriaLabel ?? (typeof confirmLabel === "string" ? confirmLabel : ariaLabel)) + : ariaLabel; + let nextVariant = variant; + if (action.confirming) nextVariant = "filled"; + let nextChildren = children; + if (action.confirming) nextChildren = confirmationChildren ?? <IconCheck size="1rem" />; + + return ( + <> + <ActionIcon + {...props} + ref={action.actionRef} + variant={nextVariant} + disabled={disabled || pending || action.submitting} + loading={loading || action.submitting} + onClick={() => void action.handleClick().catch(() => undefined)} + onBlur={action.handleBlur} + aria-label={nextAriaLabel} + data-confirming={action.confirming || undefined} + > + {nextChildren} + </ActionIcon> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; + +type InlineConfirmMenuItemProps = Omit<MenuItemProps, "children" | "closeMenuOnClick" | "disabled" | "onClick"> & + InlineConfirmActionOptions & { + children: ReactNode; + }; + +export const InlineConfirmMenuItem = ({ + children, + confirmLabel, + onConfirm, + disabled, + pending, + timeout, + ...props +}: InlineConfirmMenuItemProps) => { + const action = useInlineConfirmAction({ confirmLabel, onConfirm, disabled, pending, timeout }); + const label = action.confirming ? confirmLabel : children; + + return ( + <> + <Menu.Item + {...props} + ref={action.actionRef} + disabled={disabled || pending || action.submitting} + closeMenuOnClick={action.confirming} + onClick={() => void action.handleClick().catch(() => undefined)} + aria-label={typeof label === "string" ? label : undefined} + data-confirming={action.confirming || undefined} + > + {label} + </Menu.Item> + <span aria-live="polite" style={visuallyHidden}> + {action.confirming ? confirmLabel : null} + </span> + </> + ); +}; diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index f8552e94a..950536175 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1 +1,2 @@ +export * from "./use-is-mobile"; export * from "./use-translated-mantine-react-table"; diff --git a/packages/ui/src/hooks/use-is-mobile.ts b/packages/ui/src/hooks/use-is-mobile.ts new file mode 100644 index 000000000..8cf9cec1e --- /dev/null +++ b/packages/ui/src/hooks/use-is-mobile.ts @@ -0,0 +1,9 @@ +"use client"; + +import { useMantineTheme } from "@mantine/core"; +import { useMediaQuery } from "@mantine/hooks"; + +export const useIsMobile = () => { + const theme = useMantineTheme(); + return useMediaQuery(`(max-width: ${theme.breakpoints.sm})`); +}; diff --git a/packages/widgets/src/_inputs/form.ts b/packages/widgets/src/_inputs/form.ts index 207eee02d..a0199b55f 100644 --- a/packages/widgets/src/_inputs/form.ts +++ b/packages/widgets/src/_inputs/form.ts @@ -4,5 +4,4 @@ import { createFormContext } from "@homarr/form"; import type { WidgetEditModalState } from "../modals/widget-edit-modal"; -export const [FormProvider, useFormContext, useForm] = - createFormContext<Omit<WidgetEditModalState, "advancedOptions">>(); +export const [FormProvider, useFormContext, useForm] = createFormContext<WidgetEditModalState>(); diff --git a/packages/widgets/src/_inputs/widget-location-input.spec.tsx b/packages/widgets/src/_inputs/widget-location-input.spec.tsx index 5595f8884..9eeb9b194 100644 --- a/packages/widgets/src/_inputs/widget-location-input.spec.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.spec.tsx @@ -1,5 +1,7 @@ // @vitest-environment jsdom +/* eslint-disable unicorn/consistent-function-scoping -- Component mocks are intentionally colocated with their module factory. */ + import { act } from "react"; import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; @@ -30,6 +32,7 @@ vi.mock("@mantine/core", () => { return { Button: Container, + Collapse: Container, Fieldset: Container, Group: Container, NumberInput: Input, @@ -39,11 +42,6 @@ vi.mock("@mantine/core", () => { }; }); -vi.mock("@homarr/modals", () => ({ - createModal: () => ({ withOptions: () => null }), - useModalAction: () => ({ openModal: vi.fn() }), -})); - vi.mock("@homarr/translation/client", () => ({ useCurrentIntlLocale: () => "en", useI18n: () => (key: string) => key, diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx index 9f1a26b1a..eb874b427 100644 --- a/packages/widgets/src/_inputs/widget-location-input.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -1,17 +1,18 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ActionIcon, Alert, Anchor, Button, + Collapse, Fieldset, Group, Loader, NumberInput, + Paper, Stack, - Table, Text, TextInput, Tooltip, @@ -20,7 +21,6 @@ import { IconClick, IconListSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { createModal, useModalAction } from "@homarr/modals"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { formatLocalizedCompactNumber } from "../common/locale"; @@ -33,7 +33,7 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp const t = useWidgetInputTranslation(kind, property); const tLocation = useI18n("widget.common.location"); const form = useFormContext(); - const { openModal } = useModalAction(LocationSearchModal); + const [searchQuery, setSearchQuery] = useState<string | null>(null); const fieldPath = `options.${property}`; const inputProps = form.getInputProps(fieldPath); const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue; @@ -58,26 +58,23 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp handleChange(location); form.clearFieldError(`options.${property}.latitude`); form.clearFieldError(`options.${property}.longitude`); + setSearchQuery(null); }, [form, handleChange, property], ); const onSearch = useCallback(() => { if (!selectionEnabled) return; + setSearchQuery(value.name); + }, [selectionEnabled, value.name]); - openModal({ - query: value.name, - onLocationSelect, - }); - }, [selectionEnabled, value.name, onLocationSelect, openModal]); - - form.watch(`options.${property}.latitude`, ({ value }) => { - if (typeof value !== "number") return; + form.watch(`options.${property}.latitude`, ({ value: nextValue }) => { + if (typeof nextValue !== "number") return; form.setFieldValue(`options.${property}.name`, unknownLocation); }); - form.watch(`options.${property}.longitude`, ({ value }) => { - if (typeof value !== "number") return; + form.watch(`options.${property}.longitude`, ({ value: nextValue }) => { + if (typeof nextValue !== "number") return; form.setFieldValue(`options.${property}.name`, unknownLocation); }); @@ -121,6 +118,16 @@ export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInp value={longitudeInputProps.value ?? value.longitude} /> </Group> + + <Collapse expanded={searchQuery !== null}> + {searchQuery && ( + <LocationSearchResults + query={searchQuery} + onLocationSelect={onLocationSelect} + onClose={() => setSearchQuery(null)} + /> + )} + </Collapse> </Stack> </Fieldset> ); @@ -143,124 +150,123 @@ type LocationOnChange = ( }, ) => void; -interface LocationSearchInnerProps { +interface LocationSearchResultsProps { query: string; onLocationSelect: (location: OptionLocation) => void; + onClose: () => void; } -const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, innerProps }) => { - const t = useI18n("widget.common.location.table"); +const LocationSearchResults = ({ query, onLocationSelect, onClose }: LocationSearchResultsProps) => { + const tLocation = useI18n("widget.common.location"); const tCommon = useI18n("common"); - const { data, isPending, error } = clientApi.location.searchCity.useQuery({ - query: innerProps.query, - }); + const { data, isPending, error, refetch } = clientApi.location.searchCity.useQuery({ query }); if (error) { return ( - <Alert title={tCommon("error")} color="red"> - {tCommon("action.tryAgain")} - </Alert> + <Stack gap="xs"> + <Alert title={tCommon("error")} color="red"> + <Button variant="light" color="red" size="compact-sm" onClick={() => void refetch()}> + {tCommon("action.tryAgain")} + </Button> + </Alert> + <Group justify="right"> + <Button variant="default" size="compact-sm" onClick={onClose}> + {tCommon("action.close")} + </Button> + </Group> + </Stack> ); } return ( - <Stack> - <Table striped> - <Table.Thead> - <Table.Tr> - <Table.Th style={{ width: "70%" }}>{t("header.city")}</Table.Th> - <Table.Th style={{ width: "50%" }}>{t("header.country")}</Table.Th> - <Table.Th>{t("header.coordinates")}</Table.Th> - <Table.Th>{t("header.population")}</Table.Th> - <Table.Th style={{ width: 40 }} /> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {isPending && ( - <Table.Tr> - <Table.Td colSpan={5}> - <Group justify="center"> - <Loader /> - </Group> - </Table.Td> - </Table.Tr> - )} - {data?.results.map((city) => ( - <LocationSelectTableRow - key={city.id} - city={city} - onLocationSelect={innerProps.onLocationSelect} - closeModal={actions.closeModal} - /> - ))} - </Table.Tbody> - </Table> + <Stack component="section" aria-label={tLocation("search")} gap="xs"> + <Stack gap={4} mah={320} style={{ overflow: "hidden auto" }}> + {isPending && ( + <Group justify="center" py="md"> + <Loader /> + </Group> + )} + {!isPending && data?.results.length === 0 && ( + <Text ta="center" c="dimmed" py="md"> + {tCommon("noResults")} + </Text> + )} + {data?.results.map((city) => ( + <LocationSelectResult key={city.id} city={city} onLocationSelect={onLocationSelect} /> + ))} + </Stack> <Group justify="right"> - <Button variant="light" onClick={actions.closeModal}> - {tCommon("action.cancel")} + <Button variant="default" size="compact-sm" onClick={onClose}> + {tCommon("action.close")} </Button> </Group> </Stack> ); -}).withOptions({ - defaultTitle(t) { - return t("widget.common.location.search"); - }, - size: "xl", -}); +}; -interface LocationSearchTableRowProps { +interface LocationSelectResultProps { city: RouterOutputs["location"]["searchCity"]["results"][number]; onLocationSelect: (location: OptionLocation) => void; - closeModal: () => void; } -const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => { +const LocationSelectResult = ({ city, onLocationSelect }: LocationSelectResultProps) => { const t = useI18n("widget.common.location.table"); const locale = useCurrentIntlLocale(); + let populationLabel = t("population.fallback"); + if (city.population) { + populationLabel = `${t("header.population")}: ${formatLocalizedCompactNumber(city.population, locale)}`; + } const onSelect = useCallback(() => { onLocationSelect({ name: city.name, latitude: city.latitude, longitude: city.longitude, }); - closeModal(); - }, [city, onLocationSelect, closeModal]); + }, [city, onLocationSelect]); return ( - <Table.Tr> - <Table.Td> - <Text style={{ whiteSpace: "nowrap" }}>{city.name}</Text> - </Table.Td> - <Table.Td> - <Text style={{ whiteSpace: "nowrap" }}>{city.country}</Text> - </Table.Td> - <Table.Td> - <Anchor target="_blank" href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`}> - <Text style={{ whiteSpace: "nowrap" }}> - {city.latitude}, {city.longitude} + <Paper withBorder p="xs"> + <Group justify="space-between" align="flex-start" wrap="nowrap"> + <Stack gap={2} miw={0}> + <Text fw={500} style={{ overflowWrap: "anywhere" }}> + {city.name} </Text> - </Anchor> - </Table.Td> - <Table.Td> - {city.population ? ( - <Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text> - ) : ( - <Text c="gray"> {t("population.fallback")}</Text> - )} - </Table.Td> - <Table.Td> + <Text size="xs" c="dimmed" style={{ overflowWrap: "anywhere" }}> + {city.country} + </Text> + <Group gap="xs" wrap="wrap"> + <Anchor + size="xs" + target="_blank" + href={`https://www.google.com/maps/place/${city.latitude},${city.longitude}`} + > + {city.latitude}, {city.longitude} + </Anchor> + <Text size="xs" c="dimmed"> + {populationLabel} + </Text> + </Group> + </Stack> <Tooltip label={t("action.select", { city: city.name, countryCode: city.country_code ?? "??", })} > - <ActionIcon color="red" variant="subtle" onClick={onSelect}> + <ActionIcon + flex="0 0 auto" + color="red" + variant="subtle" + onClick={onSelect} + aria-label={t("action.select", { + city: city.name, + countryCode: city.country_code ?? "??", + })} + > <IconClick size="var(--mantine-font-size-md)" /> </ActionIcon> </Tooltip> - </Table.Td> - </Table.Tr> + </Group> + </Paper> ); }; 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 <Text className="app-title" fw={700} - size={isTiny ? "xs" : "sm"} + size={isTiny ? rem(8) : "sm"} ta={isColumnLayout ? "center" : undefined} > {app.name} </Text> )} - {options.descriptionDisplayMode === "normal" && !isTiny && ( + {options.descriptionDisplayMode === "normal" && ( <Text className="app-description" - size="sm" + size={isTiny ? rem(8) : "sm"} ta={isColumnLayout ? "center" : undefined} c="dimmed" - lineClamp={Math.max(1, Math.floor((height - 48) / 18))} + lineClamp={4} > {app.description?.split("\n").map((line, index) => ( <Fragment key={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%", }} /> </Flex> diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index f239961f5..9caba6dd2 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -7,16 +7,10 @@ import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homar import { useI18n } from "@homarr/translation/client"; import { zoomCompensatedSize } from "@homarr/ui"; +import { getCompactStatLayout } from "../common/compact-stat-layout"; 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,11 +27,49 @@ const statTileClassByCompact = { false: "", } as const; +const statTileClassByAdvanced = { + true: classes.statTileAdvanced, + false: "", +} as const; + +const rootClassByLayout = { + default: "", + short: classes.rootShort, + narrowShort: classes.rootShort, +} as const; + +const gridClassByLayout = { + default: "", + short: classes.gridShort, + narrowShort: classes.gridShort, +} as const; + +const statTileClassByLayout = { + default: "", + short: classes.statTileShort, + narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`, +} as const; + +const statLabelClassByLayout = { + default: "", + short: classes.statLabelShort, + narrowShort: classes.statLabelShort, +} as const; + +const statValueClassByLayout = { + default: "", + short: "", + narrowShort: classes.statValueNarrowShort, +} 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; } @@ -47,29 +79,45 @@ 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 iconSize = getIconSize(width, compact); + const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced; + let defaultGridCols = getGridCols(width, visibleStats.length, compact); + if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) { + defaultGridCols = 1; + } + const layout = getCompactStatLayout({ + width, + height, + visibleCount: visibleStats.length, + compactDisplay: !advanced, + defaultColumns: defaultGridCols, + defaultIconSize: getIconSize(width, compact), + }); return ( - <div className={`${classes.root} ${rootClassByCompact[compactKey]}`}> + <div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}> {visibleStats.length > 0 && ( <div - className={`${classes.grid} ${gridClassByCompact[compactKey]}`} - style={{ "--stat-cols": gridCols } as CSSProperties} + className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`} + style={{ "--stat-cols": layout.columns } as CSSProperties} > {visibleStats.map(({ optionKey, statKey, value, Icon }) => ( - <div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}> - <Icon className={classes.statIcon} style={zoomCompensatedSize(iconSize)} stroke={1.5} /> - <span className={classes.statValue}>{value}</span> - <span className={classes.statLabel}>{t(statKey as never)}</span> + <div + key={optionKey} + className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`} + > + <Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} /> + <span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span> + <span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}> + {t(statKey as never)} + </span> </div> ))} </div> diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts index 77534fec7..1d6d4b2a7 100644 --- a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts +++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; -import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared"; +import { getGridCols, getIconSize, getVisibleStats } from "./shared"; describe("getGridCols", () => { test("caps columns to visible stat count", () => { @@ -37,17 +37,6 @@ describe("getIconSize", () => { }); }); -describe("getVisibleStatLimit", () => { - test("keeps compact metrics legible when height is constrained", () => { - expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1); - expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3); - }); - - test("preserves every configured metric outside compact mode", () => { - expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5); - }); -}); - describe("getVisibleStats", () => { const navidromeStats: NavidromeDashboardData = { artistCount: 100, @@ -117,21 +106,3 @@ describe("getVisibleStats", () => { expect(typeof result[0]?.value).toBe("string"); }); }); - -describe("prioritizeVisibleStats", () => { - test("puts an active session first only on compact surfaces", () => { - const stats = getVisibleStats( - "audiobookshelf", - { showLibraryCount: true, showActiveSessions: true }, - { - libraryCount: 3, - totalAudiobooks: 150, - totalPodcasts: 20, - totalListeningTimeSeconds: 86400, - activeSessions: 2, - }, - ); - expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions"); - expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount"); - }); -}); diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css index 4a16abfb6..95bfd1af7 100644 --- a/packages/widgets/src/audio-stats/component.module.css +++ b/packages/widgets/src/audio-stats/component.module.css @@ -14,6 +14,11 @@ gap: clamp(2px, 1cqi, 4px); } +.rootShort { + padding: 4px; + gap: 2px; +} + .grid { display: grid; flex: 1; @@ -29,6 +34,10 @@ gap: clamp(2px, 1cqi, 4px); } +.gridShort { + gap: 2px; +} + .statTile { display: flex; flex-direction: column; @@ -40,16 +49,31 @@ 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); border-radius: var(--mantine-radius-sm); } +.statTileShort { + gap: 2px; + padding: 3px; +} + +.statTileNarrowShort { + flex-direction: row; + gap: 2px; + padding: 1px 3px; +} + .statIcon { color: var(--mantine-color-dimmed); flex-shrink: 0; @@ -57,7 +81,12 @@ } .statLabel { - font-size: clamp(8px, 2.5cqi, 11px); + flex-shrink: 0; + 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 +97,32 @@ } .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)) + ); +} + +.statLabelShort { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .statValue { - font-size: clamp(13px, 5cqi, 20px); + flex-shrink: 0; + 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 +134,16 @@ } .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)) + ); +} + +.statValueNarrowShort { + font-size: calc(11px * var(--board-canvas-ui-scale, 1)); + line-height: 1; } .emptyState { diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index f4aaca4a8..4571f5533 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -18,6 +18,7 @@ export default function AudioStatsWidget({ options, width, height, + displayScale = 1, displayMode = "compact", widgetRuntimeRef, }: WidgetComponentProps<"audioStats">) { @@ -39,14 +40,22 @@ export default function AudioStatsWidget({ const currentStreams = streamResults ?? []; if (!response) return <WidgetEmptyState />; + let responsiveWidth = width; + let responsiveHeight = height; + if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) { + responsiveWidth *= displayScale; + responsiveHeight *= displayScale; + } + const summary = ( <AudioStatsContent backend={response.kind} stats={response.data} - options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }} + options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }} showAllStats={displayMode === "advanced"} - width={width} - height={height} + advanced={displayMode === "advanced"} + width={responsiveWidth} + height={responsiveHeight} /> ); if (displayMode === "compact") { diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts index 75a875b6a..f31f55592 100644 --- a/packages/widgets/src/audio-stats/shared.ts +++ b/packages/widgets/src/audio-stats/shared.ts @@ -156,23 +156,6 @@ export const getGridCols = (width: number, visibleCount: number, compact: boolea return Math.min(maxCols, Math.max(visibleCount, 1)); }; -export const getVisibleStatLimit = (width: number, height: number, visibleCount: number, compact: boolean): number => { - if (!compact) return visibleCount; - - const columns = getGridCols(width, visibleCount, true); - const rows = Math.max(1, Math.floor(Math.max(0, height - 8) / 44)); - return Math.min(visibleCount, columns * rows); -}; - -export const prioritizeVisibleStats = (stats: VisibleStat[], compact: boolean): VisibleStat[] => { - if (!compact) return stats; - return stats.toSorted( - (left, right) => - Number(right.statKey === "activeSessions" && Number(right.value) > 0) - - Number(left.statKey === "activeSessions" && Number(left.value) > 0), - ); -}; - const fallbackIconSize = { true: 14, false: 16 } as const; export const getIconSize = (width: number, compact: boolean): number => { diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 083d63ee6..94e32c52a 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({ <Stack gap={6}> <Group gap={6}> <IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-6)" /> - <Text size="xs" fw={600} c="red"> + <Text size="xs" fw={600} c="red" tt={isAdvanced ? undefined : "uppercase"}> {t("status.triggered")} ({triggeredAlerts.length}) </Text> </Group> @@ -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} /> ))} </Stack> )} - {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && <Divider />} + {triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />} - {showOkAlerts && okAlerts.length > 0 && ( + {okAlerts.length > 0 && ( <Stack gap={6}> <Group gap={6}> <IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" /> - <Text size="xs" fw={600} c="dimmed"> + <Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}> {t("status.ok")} ({okAlerts.length}) </Text> </Group> @@ -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} /> ))} </Stack> @@ -195,7 +179,7 @@ export default function BeszelAlertsWidget({ <Stack gap={6}> <Group gap={6}> <IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} /> - <Text size="xs" fw={600} c="dimmed"> + <Text size="xs" fw={600} c="dimmed" tt={isAdvanced ? undefined : "uppercase"}> {t("history")} </Text> </Group> @@ -250,7 +234,7 @@ interface AlertRowProps { systemName: string; integrationName?: string; triggered: boolean; - showDescription: boolean; + isAdvanced: boolean; } const unitSuffixMap: Record<string, string> = { @@ -268,49 +252,46 @@ const unitSuffixMap: Record<string, string> = { Status: "", }; -function formatAlertDescription(name: string, value: number, min: number): string { - const suffix = unitSuffixMap[name] ?? ""; - if (name === "Status") return `down for ${min} min`; - 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 t = useI18n("widget.beszelAlerts"); const Icon = alertIconMap[name] ?? Server; - const description = formatAlertDescription(name, value, min); + const suffix = unitSuffixMap[name] ?? ""; + let description = t("alertDescription.threshold", { value: `${value}${suffix}`, minutes: min }); + if (name === "Status") { + description = t("alertDescription.statusDuration", { minutes: 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 ( - <Tooltip label={description} disabled={showDescription} withArrow> - <Group - wrap="nowrap" - gap="xs" - py={showDescription ? 4 : 2} - px={8} - style={{ - borderRadius: "var(--mantine-radius-sm)", - border: `1px solid var(--mantine-color-${triggered ? "red" : "gray"}-4)`, - backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)", - }} - > - <Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} /> - <Stack gap={0} style={{ flex: 1, minWidth: 0 }}> - <Group gap={6} wrap="nowrap"> - <Text size="xs" fw={600} truncate> - {integrationName ? `${systemName} · ${integrationName}` : systemName} - </Text> - <Text size="xs" c="dimmed"> - · - </Text> - <Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> - {name} - </Text> - </Group> - {showDescription && ( - <Text size="xs" c="dimmed" truncate> - {description} - </Text> - )} - {!showDescription && <VisuallyHidden>{description}</VisuallyHidden>} - </Stack> - </Group> - </Tooltip> + <Group wrap="nowrap" gap="xs" py={4} pr={8} pl={isAdvanced ? 8 : 12} style={rowStyle}> + <Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} /> + <Stack gap={0} style={{ flex: 1, minWidth: 0 }}> + <Group gap={6} wrap="nowrap"> + <Text size="xs" fw={600} truncate> + {integrationName ? `${systemName} · ${integrationName}` : systemName} + </Text> + <Text size="xs" c="dimmed"> + · + </Text> + <Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> + {name} + </Text> + </Group> + <Text size="xs" c="dimmed" truncate> + {description} + </Text> + </Stack> + </Group> ); } diff --git a/packages/widgets/src/common/compact-stat-layout.ts b/packages/widgets/src/common/compact-stat-layout.ts new file mode 100644 index 000000000..f644ab795 --- /dev/null +++ b/packages/widgets/src/common/compact-stat-layout.ts @@ -0,0 +1,74 @@ +const SHORT_HEIGHT_BREAKPOINT = 120; +const NARROW_SHORT_WIDTH_BREAKPOINT = 120; +const NARROW_SHORT_ICON_SIZE = 12; +const SHORT_GRID_HORIZONTAL_INSET = 8; +const SHORT_GRID_GAP = 2; +const SHORT_ICON_WIDTH_RATIO = 0.44; +const SHORT_ICON_SIZE_CAP = 40; + +export type CompactStatLayoutState = "default" | "short" | "narrowShort"; + +interface CompactStatLayoutInput { + width: number; + height: number; + visibleCount: number; + /** Compact board display mode, independent of widget-specific compact styling options. */ + compactDisplay: boolean; + defaultColumns: number; + defaultIconSize: number; +} + +interface CompactStatLayout { + state: CompactStatLayoutState; + columns: number; + iconSize: number; +} + +export function getCompactStatLayout({ + width, + height, + visibleCount, + compactDisplay, + defaultColumns, + defaultIconSize, +}: CompactStatLayoutInput): CompactStatLayout { + if (!compactDisplay) { + return { + state: "default", + columns: defaultColumns, + iconSize: defaultIconSize, + }; + } + + if (width < NARROW_SHORT_WIDTH_BREAKPOINT) { + return { + state: "narrowShort", + columns: 1, + iconSize: NARROW_SHORT_ICON_SIZE, + }; + } + + if (height >= SHORT_HEIGHT_BREAKPOINT) { + return { + state: "default", + columns: defaultColumns, + iconSize: defaultIconSize, + }; + } + + return { + state: "short", + columns: Math.max(visibleCount, 1), + iconSize: getShortIconSize(width, visibleCount, defaultIconSize), + }; +} + +function getShortIconSize(width: number, visibleCount: number, defaultIconSize: number): number { + const statCount = Math.max(visibleCount, 1); + const totalGridGap = Math.max(statCount - 1, 0) * SHORT_GRID_GAP; + const availableWidth = Math.max(width - SHORT_GRID_HORIZONTAL_INSET - totalGridGap, 0); + const availableWidthPerStat = availableWidth / statCount; + const proportionalIconSize = Math.round(availableWidthPerStat * SHORT_ICON_WIDTH_RATIO); + const cappedIconSize = Math.min(proportionalIconSize, SHORT_ICON_SIZE_CAP); + return Math.max(defaultIconSize, cappedIconSize); +} diff --git a/packages/widgets/src/common/query-state.spec.ts b/packages/widgets/src/common/query-state.spec.ts index c90c0ae50..63f2fd614 100644 --- a/packages/widgets/src/common/query-state.spec.ts +++ b/packages/widgets/src/common/query-state.spec.ts @@ -12,6 +12,11 @@ describe("getUsableWidgetQueryData", () => { expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data); }); + test("throws access-denied refetches instead of rendering stale cached data", () => { + const error = { data: { code: "FORBIDDEN" } }; + expect(() => getUsableWidgetQueryData({ data: [{ id: "revoked" }], error })).toThrow(error); + }); + test("throws a terminal failure when no usable data exists", () => { const error = new Error("offline"); expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error); diff --git a/packages/widgets/src/common/query-state.ts b/packages/widgets/src/common/query-state.ts index a83066c0b..77e8ccfd0 100644 --- a/packages/widgets/src/common/query-state.ts +++ b/packages/widgets/src/common/query-state.ts @@ -1,3 +1,5 @@ +import { isTrpcForbiddenError } from "@homarr/api/query-cache"; + interface WidgetQueryState { data: unknown; error: unknown; @@ -9,6 +11,7 @@ interface PendingWidgetQueryState extends WidgetQueryState { /** Throws terminal query failures while preserving successful empty values and stale cached data. */ export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => { + if (isTrpcForbiddenError(query.error)) throw query.error; if (query.error && query.data === undefined) throw query.error; return query.data; }; 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} /> </div> </Stack> @@ -120,7 +115,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: isTiny={!isAdvanced && isTiny} isAdvanced={isAdvanced} widgetKey={widgetKey} - hideFooter={!isAdvanced && hideFooter} /> ))} </SimpleGrid> 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 ( <Card p={0} radius="sm" bg="transparent"> - <Group - p="xs" - justify="space-between" - wrap="nowrap" - className={classes.neutralDividerBottom} - > + <Group p="xs" justify="space-between" wrap="nowrap" className={classes.neutralDividerBottom}> <Group gap={4} wrap="nowrap" miw={0}> <Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} /> <Stack gap={0} miw={0}> @@ -102,19 +78,12 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, </Stack> </Group> <Group gap={4} wrap="nowrap"> - {isTiny && totalResources > 0 ? ( - <Tooltip label={resourceSummary}> - <Badge variant="dot" color={getBadgeColor(healthyResources, totalResources)} size="xs"> - {healthyResources}/{totalResources} - </Badge> - </Tooltip> - ) : null} - {!isTiny && options.showServers && ( + {options.showServers && ( <Badge variant="dot" color={getBadgeColor(onlineServers, instance.instanceInfo.servers.length)} size="xs"> {onlineServers}/{instance.instanceInfo.servers.length} </Badge> )} - {!isTiny && options.showApplications && ( + {options.showApplications && ( <Badge variant="dot" color={getBadgeColor(runningApps, instance.instanceInfo.applications.length)} @@ -123,7 +92,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, {runningApps}/{instance.instanceInfo.applications.length} </Badge> )} - {!isTiny && options.showServices && ( + {options.showServices && ( <Badge variant="dot" color={getBadgeColor(runningServices, instance.instanceInfo.services.length)} @@ -168,7 +137,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, )} </Accordion> - {!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />} + <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} /> </Card> ); } 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 <IconLink size="var(--mantine-font-size-xs)" /> </ActionIcon> )} - {!isTiny && logsUrl && ( + {logsUrl && ( <ActionIcon className={actionTargetClasses.root} component="a" @@ -129,12 +129,10 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow <IconFileText size="var(--mantine-font-size-xs)" /> </ActionIcon> )} - {!isTiny && ( - <Text fz="10px" c="dimmed" lineClamp={1}> - {item.projectName ?? "-"} / {item.environmentName ?? "-"} - </Text> - )} - {!isTiny && resourceTimestamp && ( + <Text fz="10px" c="dimmed" lineClamp={1}> + {item.projectName ?? "-"} / {item.environmentName ?? "-"} + </Text> + {resourceTimestamp && ( <Text fz="10px" c="dimmed" ml="auto"> {resourceTimestamp} </Text> 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({ )} </Accordion> - {!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />} + <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} /> </Stack> </ScrollArea> ); diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index f7947f7b4..979241f8a 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -179,6 +179,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void; width: number; height: number; + displayScale?: number; widgetRuntimeRef?: WidgetRuntimeRef; widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>; removeItem?: () => void; diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx deleted file mode 100644 index 02de22c0c..000000000 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useRef, useState } from "react"; -import type { NumberInputHandlers } from "@mantine/core"; -import { ActionIcon, Button, Flex, Group, Modal, NumberInput, rem, Stack, Text } from "@mantine/core"; -import { IconClockPause } from "@tabler/icons-react"; - -import { useI18n } from "@homarr/translation/client"; - -interface TimerModalProps { - opened: boolean; - close: () => void; - selectedIntegrationIds: string[]; - disableDns: (data: { duration: number; integrationId: string }) => void; -} - -const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: TimerModalProps) => { - const t = useI18n("widget.dnsHoleControls"); - const tCommon = useI18n("common"); - const [hours, setHours] = useState(0); - const [minutes, setMinutes] = useState(0); - const hoursHandlers = useRef<NumberInputHandlers>(null); - const minutesHandlers = useRef<NumberInputHandlers>(null); - - const handleSetTimer = () => { - const duration = hours * 3600 + minutes * 60; - selectedIntegrationIds.forEach((integrationId) => { - disableDns({ duration, integrationId }); - }); - setHours(0); - setMinutes(0); - close(); - }; - - return ( - <Modal - withinPortal - radius="lg" - shadow="sm" - size="sm" - opened={opened} - onClose={() => { - close(); - setHours(0); - setMinutes(0); - }} - title={t("controls.setTimer")} - > - <Flex direction="column" align="center" justify="center"> - <Stack align="flex-end"> - <Group> - <Text>{tCommon("information.hours")}</Text> - <ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}> - – - </ActionIcon> - <NumberInput - hideControls - value={hours} - onChange={(val) => setHours(Number(val))} - handlersRef={hoursHandlers} - max={999} - min={0} - step={1} - styles={{ input: { width: rem(54), textAlign: "center" } }} - /> - <ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}> - + - </ActionIcon> - </Group> - <Group> - <Text>{tCommon("information.minutes")}</Text> - <ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}> - – - </ActionIcon> - <NumberInput - hideControls - value={minutes} - onChange={(val) => setMinutes(Number(val))} - handlersRef={minutesHandlers} - max={59} - min={0} - step={1} - styles={{ input: { width: rem(54), textAlign: "center" } }} - /> - <ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}> - + - </ActionIcon> - </Group> - </Stack> - <Text ta="center" c="dimmed" my={5}> - {t("controls.unlimited")} - </Text> - <Button - variant="light" - color="red" - leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} - h="2rem" - w="12rem" - onClick={handleSetTimer} - > - {t("controls.set")} - </Button> - </Flex> - </Modal> - ); -}; - -export default TimerModal; diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index f4d991b48..dec4e56aa 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -1,5 +1,8 @@ "use client"; +/* eslint-disable react/no-unstable-nested-components -- TimerPopover uses render props to wire controlled targets. */ +/* eslint-disable import/no-unassigned-import -- The shared widget stylesheet is loaded for its side effects. */ + import "../../widgets-common.css"; import { useState } from "react"; @@ -18,7 +21,6 @@ import { Tooltip, UnstyledButton, } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; import { IconCircleFilled, IconClockPause, IconPlayerPlay, IconPlayerStop } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -38,7 +40,7 @@ import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../com import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; -import TimerModal from "./TimerModal"; +import TimerPopover from "./timer-popover"; const dnsLightStatus = (enabled: boolean | undefined) => `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; @@ -57,7 +59,7 @@ export default function DnsHoleControlsWidget({ integrationIds, isEditMode, width, - height, + displayMode, }: WidgetComponentProps<typeof widgetKind>) { const board = useRequiredBoard(); // DnsHole integrations with interaction permissions @@ -123,11 +125,8 @@ export default function DnsHoleControlsWidget({ const t = useI18n("widget.dnsHoleControls"); - // Timer modal setup - const [selectedIntegrationIds, setSelectedIntegrationIds] = useState<string[]>([]); const [bulkPending, setBulkPending] = useState(false); const [bulkFailureCount, setBulkFailureCount] = useState(0); - const [opened, { close, open }] = useDisclosure(false); const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0; const actionsPending = bulkPending || isEnabling || isDisabling; @@ -177,26 +176,29 @@ export default function DnsHoleControlsWidget({ </Button> </Tooltip> - <Tooltip label={t("controls.setTimer")}> - <Button - aria-label={t("controls.setTimer")} - size="xs" - p={0} - className="dns-hole-controls-timer-all-button" - onClick={() => { - setSelectedIntegrationIds(integrationsSummaries.enabled); - open(); - }} - disabled={integrationsSummaries.enabled.length === 0 || actionsPending} - variant="light" - color="yellow" - bd={0} - radius={board.itemRadius} - flex={1} - > - <IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> - </Button> - </Tooltip> + <TimerPopover + target={(onClick) => ( + <Tooltip label={t("controls.setTimer")}> + <Button + aria-label={t("controls.setTimer")} + size="xs" + p={0} + className="dns-hole-controls-timer-all-button" + disabled={integrationsSummaries.enabled.length === 0 || actionsPending} + variant="light" + color="yellow" + bd={0} + radius={board.itemRadius} + flex={1} + onClick={onClick} + > + <IconClockPause className="dns-hole-controls-timer-all-icon" size="var(--mantine-font-size-md)" /> + </Button> + </Tooltip> + )} + selectedIntegrationIds={integrationsSummaries.enabled} + disableDns={(input) => void disableDns(input).catch(() => undefined)} + /> <Tooltip label={t("controls.disableAll")}> <Button @@ -230,13 +232,12 @@ export default function DnsHoleControlsWidget({ key={summary.integration.id} integrationsWithInteractions={integrationsWithInteractions} toggleDns={toggleDns} + disableDns={(input) => void disableDns(input).catch(() => undefined)} data={summary} - setSelectedIntegrationIds={setSelectedIntegrationIds} - open={open} t={t} hasIconColor={board.iconColor !== null} rootWidth={width} - rootHeight={height} + isAdvanced={displayMode === "advanced"} actionsPending={actionsPending} /> ))} @@ -255,13 +256,6 @@ export default function DnsHoleControlsWidget({ </Text> </Tooltip> )} - - <TimerModal - opened={opened} - close={close} - selectedIntegrationIds={selectedIntegrationIds} - disableDns={(input) => void disableDns(input).catch(() => undefined)} - /> </Stack> ); } @@ -269,26 +263,24 @@ export default function DnsHoleControlsWidget({ interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise<void>; + disableDns: (data: { duration: number; integrationId: string }) => void; data: AvailableDnsSummaryResult; - setSelectedIntegrationIds: (integrationId: string[]) => void; - open: () => void; t: ReturnType<typeof useI18n<"widget.dnsHoleControls">>; hasIconColor: boolean; rootWidth: number; - rootHeight: number; + isAdvanced: boolean; actionsPending: boolean; } const ControlsCard: React.FC<ControlsCardProps> = ({ integrationsWithInteractions, toggleDns, + disableDns, data, - setSelectedIntegrationIds, - open, t, hasIconColor, rootWidth, - rootHeight, + isAdvanced, actionsPending, }) => { const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 }); @@ -299,7 +291,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ 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 ( <Indicator @@ -382,21 +374,24 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ <IconPlayerStop size="var(--mantine-font-size-xs)" /> </ActionIcon> )} - <ActionIcon - className={actionTargetClasses.root} - aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} - onClick={() => { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - size="sm" - color="yellow" - variant="light" - display={isInteractPermitted ? undefined : "none"} - disabled={!controlEnabled || !isEnabled} - > - <IconClockPause size="var(--mantine-font-size-xs)" /> - </ActionIcon> + <TimerPopover + target={(onClick) => ( + <ActionIcon + className={actionTargetClasses.root} + aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} + size="sm" + color="yellow" + variant="light" + display={isInteractPermitted ? undefined : "none"} + disabled={!controlEnabled || !isEnabled} + onClick={onClick} + > + <IconClockPause size="var(--mantine-font-size-xs)" /> + </ActionIcon> + )} + selectedIntegrationIds={[data.integration.id]} + disableDns={disableDns} + /> </Group> )} {layout === "md" && ( @@ -445,24 +440,27 @@ const ControlsCard: React.FC<ControlsCardProps> = ({ </Flex> </Flex> {layout === "md" && ( - <ActionIcon - aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} - className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)} - display={isInteractPermitted ? undefined : "none"} - disabled={!controlEnabled || !isEnabled} - color="yellow" - size={30} - radius={board.itemRadius} - bd={0} - ms={"auto"} - variant="subtle" - onClick={() => { - setSelectedIntegrationIds([data.integration.id]); - open(); - }} - > - <IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> - </ActionIcon> + <TimerPopover + target={(onClick) => ( + <ActionIcon + aria-label={`${t("controls.setTimer")}: ${data.integration.name}`} + className={combineClasses("dns-hole-controls-item-timer-button", actionTargetClasses.root)} + display={isInteractPermitted ? undefined : "none"} + disabled={!controlEnabled || !isEnabled} + color="yellow" + size={30} + radius={board.itemRadius} + bd={0} + ms={"auto"} + variant="subtle" + onClick={onClick} + > + <IconClockPause className="dns-hole-controls-item-timer-icon" size="var(--mantine-font-size-xl)" /> + </ActionIcon> + )} + selectedIntegrationIds={[data.integration.id]} + disableDns={disableDns} + /> )} </Flex> </Card> diff --git a/packages/widgets/src/dns-hole/controls/timer-popover.tsx b/packages/widgets/src/dns-hole/controls/timer-popover.tsx new file mode 100644 index 000000000..68ef981e0 --- /dev/null +++ b/packages/widgets/src/dns-hole/controls/timer-popover.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; +import type { NumberInputHandlers } from "@mantine/core"; +import { ActionIcon, Button, Flex, Group, NumberInput, Popover, rem, Stack, Text } from "@mantine/core"; +import { IconClockPause } from "@tabler/icons-react"; +import { useDisclosure } from "@mantine/hooks"; + +import { useI18n } from "@homarr/translation/client"; + +interface TimerPopoverProps { + target: (onClick: () => void) => ReactNode; + selectedIntegrationIds: string[]; + disableDns: (data: { duration: number; integrationId: string }) => void; +} + +const TimerPopover = ({ target, selectedIntegrationIds, disableDns }: TimerPopoverProps) => { + const t = useI18n("widget.dnsHoleControls"); + const tCommon = useI18n("common"); + const [opened, { close, open }] = useDisclosure(false); + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(0); + const hoursHandlers = useRef<NumberInputHandlers>(null); + const minutesHandlers = useRef<NumberInputHandlers>(null); + + const handleSetTimer = () => { + const duration = hours * 3600 + minutes * 60; + selectedIntegrationIds.forEach((integrationId) => { + disableDns({ duration, integrationId }); + }); + setHours(0); + setMinutes(0); + close(); + }; + + const reset = () => { + close(); + setHours(0); + setMinutes(0); + }; + + const handleToggle = () => { + if (opened) { + reset(); + return; + } + open(); + }; + + return ( + <Popover + opened={opened} + onChange={(nextOpened) => { + if (nextOpened) open(); + if (!nextOpened) reset(); + }} + position="bottom" + shadow="sm" + > + <Popover.Target>{target(handleToggle)}</Popover.Target> + <Popover.Dropdown> + <Stack gap="sm" maw={280}> + <Text fw={500}>{t("controls.setTimer")}</Text> + <Flex direction="column" align="center" justify="center"> + <Stack align="flex-end"> + <Group> + <Text>{tCommon("information.hours")}</Text> + <ActionIcon + size={35} + variant="default" + onClick={() => hoursHandlers.current?.decrement()} + aria-label={`− ${tCommon("information.hours")}`} + > + – + </ActionIcon> + <NumberInput + hideControls + value={hours} + onChange={(val) => setHours(Number(val))} + handlersRef={hoursHandlers} + aria-label={tCommon("information.hours")} + max={999} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + <ActionIcon + size={35} + variant="default" + onClick={() => hoursHandlers.current?.increment()} + aria-label={`+ ${tCommon("information.hours")}`} + > + + + </ActionIcon> + </Group> + <Group> + <Text>{tCommon("information.minutes")}</Text> + <ActionIcon + size={35} + variant="default" + onClick={() => minutesHandlers.current?.decrement()} + aria-label={`− ${tCommon("information.minutes")}`} + > + – + </ActionIcon> + <NumberInput + hideControls + value={minutes} + onChange={(val) => setMinutes(Number(val))} + handlersRef={minutesHandlers} + aria-label={tCommon("information.minutes")} + max={59} + min={0} + step={1} + styles={{ input: { width: rem(54), textAlign: "center" } }} + /> + <ActionIcon + size={35} + variant="default" + onClick={() => minutesHandlers.current?.increment()} + aria-label={`+ ${tCommon("information.minutes")}`} + > + + + </ActionIcon> + </Group> + </Stack> + <Text ta="center" c="dimmed" my={5}> + {t("controls.unlimited")} + </Text> + <Button + variant="light" + color="red" + leftSection={<IconClockPause size="var(--mantine-font-size-xl)" />} + h="2rem" + w="12rem" + onClick={handleSetTimer} + > + {t("controls.set")} + </Button> + </Flex> + </Stack> + </Popover.Dropdown> + </Popover> + ); +}; + +export default TimerPopover; diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 4004dd52e..0858ae075 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -16,7 +16,6 @@ import { Stack, Text, Tooltip, - VisuallyHidden, } from "@mantine/core"; import { IconBrandDocker, @@ -111,33 +110,11 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => { ); }; -const ContainerStateDot = ({ state }: { state: ContainerState }) => { - const t = useI18n("docker.field.state.option"); - const label = t(state); - - return ( - <Tooltip label={label} withArrow> - <Box component="span" style={{ display: "inline-flex", flexShrink: 0 }}> - <Box - component="span" - aria-hidden="true" - w={8} - h={8} - bg={`${containerStateColorMap[state]}.6`} - style={{ borderRadius: "50%" }} - /> - <VisuallyHidden>{label}</VisuallyHidden> - </Box> - </Tooltip> - ); -}; - const createColumns = ( t: ReturnType<typeof useI18n<"docker">>, tCommon: ReturnType<typeof useI18n<"common">>, handlers: ContainerActionHandlers, sortingEnabled: boolean, - inlineState: boolean, ): DataTableColumn<DockerContainer>[] => [ { accessor: "name", @@ -147,7 +124,6 @@ const createColumns = ( sortable: sortingEnabled, render: (container) => ( <Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}> - {inlineState && <ContainerStateDot state={container.state} />} <Avatar variant="outline" radius="sm" @@ -387,14 +363,12 @@ export default function DockerWidget({ () => 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({ <Box style={{ flex: 1, minHeight: 0 }}> <HomarrDataTable isEditMode={isEditMode} - cellPadding="2px 8px" + cellPadding={isAdvanced || width < 400 ? "2px 8px" : "4px 8px"} className="docker-table" rowCursor="default" fetching={isFetching && containers.length === 0} diff --git a/packages/widgets/src/docker/layout.spec.ts b/packages/widgets/src/docker/layout.spec.ts index 190ed9542..c659fd10e 100644 --- a/packages/widgets/src/docker/layout.spec.ts +++ b/packages/widgets/src/docker/layout.spec.ts @@ -5,27 +5,34 @@ import { getDockerColumnVisibility, getDockerFooterVisibility } from "./layout"; const columns = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const; describe("getDockerColumnVisibility", () => { - 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<DockerColumn, boolean> => { 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({ /> </Box> - {(isAdvanced || height >= 96) && ( - <WidgetFooter - key={`footer-${displayMode}`} - clients={clients} - totalSpeed={totalSpeed} - totalUpSpeed={totalUpSpeed} - globalRatio={globalRatio} - hasTorrents={hasTorrents} - clientFilter={clientFilter} - setClientFilter={setClientFilter} - statusFilter={statusFilter} - setStatusFilter={setStatusFilter} - availableStatuses={availableStatuses} - pauseQueue={mutatePauseQueue} - resumeQueue={mutateResumeQueue} - showStats={statsDisplay.visible} - toggleStats={statsDisplay.canToggle ? toggleStats : undefined} - /> - )} + <WidgetFooter + key={`footer-${displayMode}`} + clients={clients} + totalSpeed={totalSpeed} + totalUpSpeed={totalUpSpeed} + globalRatio={globalRatio} + hasTorrents={hasTorrents} + clientFilter={clientFilter} + setClientFilter={setClientFilter} + statusFilter={statusFilter} + setStatusFilter={setStatusFilter} + availableStatuses={availableStatuses} + pauseQueue={mutatePauseQueue} + resumeQueue={mutateResumeQueue} + showStats={statsDisplay.visible} + toggleStats={statsDisplay.canToggle ? toggleStats : undefined} + /> {contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} t={t} />} </Stack> 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 = ({ )} </Flex> - {showInterfaces && ( - <InterfacesPanel - summary={interfaces ?? []} - hasResult={interfacesLoaded} - hasError={interfacesError} - isAdvanced={isAdvanced} - accordionValue={accordionValue} - setAccordionValue={setAccordionValue} - errorLabel={errorLabel} - errorBadgeLabel={errorBadgeLabel} - noDataLabel={noDataLabel} - loadingLabel={loadingLabel} - label={interfacesLabel} - /> - )} + <InterfacesPanel + summary={interfaces ?? []} + hasResult={interfacesLoaded} + hasError={interfacesError} + isAdvanced={isAdvanced} + accordionValue={accordionValue} + setAccordionValue={setAccordionValue} + errorLabel={errorLabel} + errorBadgeLabel={errorBadgeLabel} + noDataLabel={noDataLabel} + loadingLabel={loadingLabel} + label={interfacesLabel} + /> </Stack> </Paper> ); diff --git a/packages/widgets/src/iframe/component.spec.ts b/packages/widgets/src/iframe/component.spec.ts index a5f3b5835..8c7dd134d 100644 --- a/packages/widgets/src/iframe/component.spec.ts +++ b/packages/widgets/src/iframe/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component"; +import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component"; const permissions = { allowFullScreen: true, @@ -18,12 +18,6 @@ describe("iframe policy", () => { expect(getSandboxFlags(permissions)).toContain("allow-modals"); }); - it("accepts only HTTP protocols", () => { - expect(isSupportedProtocol("https://example.com")).toBe(true); - expect(isSupportedProtocol("javascript:alert(1)")).toBe(false); - expect(isSupportedProtocol("not a url")).toBe(false); - }); - it("creates a display title without credentials, paths, or query parameters", () => { expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com"); }); diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx index c66343927..3c311b88e 100644 --- a/packages/widgets/src/iframe/component.tsx +++ b/packages/widgets/src/iframe/component.tsx @@ -1,26 +1,21 @@ "use client"; import { Box, Stack, Text, Title } from "@mantine/core"; -import { IconBrowserOff, IconProtocol } from "@tabler/icons-react"; +import { IconBrowserOff } from "@tabler/icons-react"; import { objectEntries } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { getSafeApplicationUrl } from "../common/application-url"; import classes from "./component.module.css"; export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) { const t = useI18n("widget.iframe"); const { embedUrl, allowScrolling, ...permissions } = options; - const safeEmbedUrl = getSafeApplicationUrl(embedUrl); const allowedPermissions = getAllowedPermissions(permissions); const sandboxFlags = getSandboxFlags(permissions); if (embedUrl.trim() === "") return <NoUrl />; - if (!safeEmbedUrl) { - return <UnsupportedProtocol />; - } return ( <Stack h="100%" w="100%" gap={0}> @@ -29,8 +24,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro loading="lazy" style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined} className={classes.iframe} - src={safeEmbedUrl} - title={getFrameTitle(safeEmbedUrl)} + src={embedUrl} + title={getFrameTitle(embedUrl)} allow={allowedPermissions} scrolling={allowScrolling ? "yes" : "no"} sandbox={sandboxFlags.join(" ")} @@ -42,12 +37,6 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro ); } -const supportedProtocols = ["http", "https"]; - -export const isSupportedProtocol = (url: string) => { - return getSafeApplicationUrl(url) !== undefined; -}; - const NoUrl = () => { const t = useI18n("widget.iframe"); @@ -59,21 +48,6 @@ const NoUrl = () => { ); }; -const UnsupportedProtocol = () => { - const t = useI18n("widget.iframe"); - - return ( - <Stack align="center" justify="center" h="100%"> - <IconProtocol /> - <Title order={4} ta="center"> - {t("error.unsupportedProtocol", { - supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "), - })} - - - ); -}; - export const getAllowedPermissions = ( permissions: Omit["options"], "embedUrl" | "allowScrolling">, ) => { diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts index 981ab180c..810a0b03b 100644 --- a/packages/widgets/src/iframe/index.ts +++ b/packages/widgets/src/iframe/index.ts @@ -1,7 +1,6 @@ import { IconBrowser, IconExternalLink } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; -import { getSafeApplicationUrl } from "../common/application-url"; import { optionsBuilder } from "../options"; const createOptions = () => @@ -23,17 +22,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe", supportsAdvancedFocus: false, icon: IconBrowser, contextActions: ({ options }) => { - const embedUrl = getSafeApplicationUrl(options.embedUrl); - return embedUrl - ? [ - { - key: "open-iframe", - label: "widget.common.openInNewTab.label", - icon: IconExternalLink, - onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), - }, - ] - : []; + const embedUrl = options.embedUrl; + if (typeof embedUrl !== "string" || embedUrl.trim() === "") return []; + + return [ + { + key: "open-iframe", + label: "widget.common.openInNewTab.label", + icon: IconExternalLink, + onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), + }, + ]; }, createOptions, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/immich/server-stats/component.module.css b/packages/widgets/src/immich/server-stats/component.module.css index e7ece8d6b..19436a676 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; } -@media (forced-colors: active) { - .statItem, - .statItemDense { - border-color: CanvasText; +.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, + .statItemCompact { + border: 0.0625rem solid 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..c0d809b97 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, @@ -22,7 +23,9 @@ import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import { showErrorNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; +import { iconSizes } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import { useWidgetRuntimeActions } from "../runtime-hooks"; @@ -60,6 +63,11 @@ export default function IndexerManagerWidget({ isPending, error: testAllError, } = clientApi.widget.indexerManager.testAllIndexers.useMutation({ + onError: () => + showErrorNotification({ + title: tCommon("error"), + message: t("error.testAll"), + }), onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(), }); const interactIntegrationIds = new Set(useIntegrationsWithInteractAccess().map(({ id }) => id)); @@ -67,8 +75,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 +95,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 +132,35 @@ export default function IndexerManagerWidget({ )} )} - - { - testAllIndexers(); - }} - aria-label={t("testAll")} - > - - - + {(isAdvanced || hasSmallHeight) && ( + + { + testAllIndexers(); + }} + aria-label={t("testAll")} + > + + + + )} @@ -153,7 +174,7 @@ export default function IndexerManagerWidget({ {indexersData.map(({ integrationId, integrationName, indexers, error }) => ( - {(isAdvanced || indexersData.length > 1 || error) && ( + {(isAdvanced || error) && ( {integrationName} @@ -171,14 +192,18 @@ export default function IndexerManagerWidget({ const presentation = statusPresentation[displayStatus]; return ( {indexer.name} @@ -201,14 +226,14 @@ export default function IndexerManagerWidget({ ) : displayStatus === "healthy" ? ( ) : ( )} @@ -219,6 +244,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 ?? "") || t("unknown")} + + + + + + {request.name || t("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 f1742ee9a..6967fdcff 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -35,6 +35,7 @@ import { objectEntries } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { useI18n } from "@homarr/translation/client"; +import { zoomCompensatedSize } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; @@ -48,11 +49,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 +170,6 @@ export default function MediaServerWidget({ options, integrationIds, width, - height, isEditMode, displayMode, }: WidgetComponentProps<"mediaServer">) { @@ -411,59 +409,76 @@ export default function MediaServerWidget({
- {(isAdvanced || height >= 144) && ( - - - - - {t("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-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx deleted file mode 100644 index 184fd4678..000000000 --- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { CloseButton, ColorInput, Input, Stack, TextInput, useMantineTheme } from "@mantine/core"; - -import { useForm } from "@homarr/form"; -import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; -import { useI18n } from "@homarr/translation/client"; -import { TextMultiSelect } from "@homarr/ui"; -import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; - -interface InnerProps { - advancedOptions: BoardItemAdvancedOptions; - onSuccess: (options: BoardItemAdvancedOptions) => void; -} - -export const WidgetAdvancedOptionsModal = createModal(({ actions, innerProps }) => { - const t = useI18n("item.edit"); - const theme = useMantineTheme(); - const form = useForm({ - initialValues: innerProps.advancedOptions, - }); - const handleSubmit = (values: BoardItemAdvancedOptions) => { - innerProps.onSuccess({ - ...values, - // we want to fallback to null if the title is empty - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - title: values.title?.trim() || null, - }); - actions.closeModal(); - }; - - return ( -
- - form.setFieldValue("title", "")} />} - /> - - color[6])} - rightSection={ - form.setFieldValue("borderColor", "")} - style={{ display: form.getInputProps("borderColor").value ? undefined : "none" }} - /> - } - {...form.getInputProps("borderColor")} - /> - - -
- ); -}).withOptions({ - defaultTitle(t) { - return t("item.edit.advancedOptions.title"); - }, - size: modalSizeForm, - transitionProps: { - duration: 0, - }, -}); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index ea5d1b8e3..b493f92ad 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -5,19 +5,25 @@ import { useEffect, useId, useRef, useState } from "react"; import type { ComponentType, FormEvent, PropsWithChildren } from "react"; import { + Accordion, Alert, Badge, Box, Button, Center, + CloseButton, + ColorInput, Divider, Group, + Input as MantineInput, NumberInput, SimpleGrid, Stack, Tabs, Text, + TextInput, Tooltip, + useMantineTheme, } from "@mantine/core"; import { schemaResolver } from "@mantine/form"; import { useElementSize } from "@mantine/hooks"; @@ -30,11 +36,11 @@ import { objectEntries } from "@homarr/common"; import { IntegrationProvider, useSession } from "@homarr/auth/client"; import { useOptionalBoard } from "@homarr/boards/context"; import type { WidgetKind } from "@homarr/definitions"; -import { createModal, modalSizeForm, useModalAction } from "@homarr/modals"; +import { createModal, modalSizeForm } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { SpotlightProvider } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; -import { IntegrationAvatar } from "@homarr/ui"; +import { IntegrationAvatar, TextMultiSelect } from "@homarr/ui"; import { zodErrorMap } from "@homarr/validation/form/i18n"; import { getInputForType } from "../_inputs"; @@ -47,7 +53,6 @@ import { OPTIONS_SUPER_REFINE } from "../options"; import type { IntegrationSelectOption } from "../widget-integration-select"; import { WidgetIntegrationSelect } from "../widget-integration-select"; import { WidgetCardShell, WidgetTitleBadge } from "../widget-card-shell"; -import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal"; import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form"; import { EmbeddedAppEditForm } from "./embedded-app-edit-form"; import classes from "./widget-edit-modal.module.css"; @@ -63,10 +68,14 @@ export interface WidgetEditModalSize { height: number; } +export interface WidgetPreviewDimensions extends WidgetEditModalSize { + scale?: number; +} + interface WidgetEditPreviewResizeOptions { initialSize: WidgetEditModalSize; maximumSize: WidgetEditModalSize; - getDimensions?: (size: WidgetEditModalSize) => { width: number; height: number; scale?: number }; + getDimensions?: (size: WidgetEditModalSize) => WidgetPreviewDimensions; } export interface EmbeddedIntegrationEditFormHandle { @@ -94,7 +103,7 @@ export interface WidgetEditModalProps { onIntegrationSaved?: () => void; onOpenNewIntegration?: (onCreated?: (id: string) => void) => void; previewComponent?: ComponentType>; - previewDimensions?: { width: number; height: number; scale?: number }; + previewDimensions?: WidgetPreviewDimensions; previewResize?: WidgetEditPreviewResizeOptions; previewWrapper?: ComponentType; } @@ -106,7 +115,7 @@ interface WidgetEditPreviewProps { state: WidgetEditModalState; itemId?: string; boardId?: string; - dimensions?: { width: number; height: number; scale?: number }; + dimensions?: WidgetPreviewDimensions; integrationData: IntegrationSelectOption[]; onChangeOptions: (newOptions: Record) => void; resize?: { @@ -174,7 +183,9 @@ const WidgetEditPreview = ({ }, })); let componentItemId = itemId; - if (kind === "assistant") componentItemId = `widget-preview-${generatedPreviewId}`; + if (kind === "assistant" || kind === "timer") { + componentItemId = `widget-preview-${generatedPreviewId}`; + } const isPendingCustomWidget = kind === "customApi" && !itemId; const previewOpacity = (board?.opacity ?? 100) / 100; const handleResizeValue = (dimension: keyof WidgetEditModalSize, value: string | number) => { @@ -281,6 +292,7 @@ const WidgetEditPreview = ({ integrationIds={state.integrationIds} width={sourceWidth} height={sourceHeight} + displayScale={previewScale} isEditMode={isPendingCustomWidget} displayMode="compact" boardId={boardId} @@ -313,7 +325,7 @@ export const WidgetEditModal = createModal>(({ const tCommon = useI18n("common"); const board = useOptionalBoard(); const { data: session } = useSession(); - const [advancedOptions, setAdvancedOptions] = useState(innerProps.value.advancedOptions); + const theme = useMantineTheme(); const appEditRef = useRef(null); const integrationEditHandles = useRef(new Map()); const integrationEditRefCallbacks = useRef( @@ -367,6 +379,7 @@ export const WidgetEditModal = createModal>(({ options: optionsSchema, integrationIds: integrationIdsSchema, advancedOptions: z.object({ + title: z.string().max(64).nullable(), customCssClasses: z.array(z.string()), borderColor: z.string(), }), @@ -376,7 +389,6 @@ export const WidgetEditModal = createModal>(({ validateInputOnBlur: true, validateInputOnChange: true, }); - const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const canModifyApps = session?.user.permissions.includes("app-modify-all") ?? false; const canConfigureWidget = innerProps.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); @@ -456,7 +468,10 @@ export const WidgetEditModal = createModal>(({ innerProps.onSuccessfulEdit( { ...values, - advancedOptions, + advancedOptions: { + ...values.advancedOptions, + title: values.advancedOptions.title?.trim() || null, + }, }, previewSize ?? undefined, ); @@ -518,14 +533,14 @@ export const WidgetEditModal = createModal>(({ {innerProps.previewComponent && ( >(({ PreviewWrapper={innerProps.previewWrapper} /> )} - {innerProps.previewComponent && hasWidgetSettings && ( - - )} - ); const footer = ( - - +