From 25c9955cc62955283e79ce68abe6c54b6dde8cb2 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 1 Aug 2026 00:43:28 +0200 Subject: [PATCH] feat: rebuild board grid experience --- PRODUCT.md | 29 + .../after-the-installation.mdx | 118 +- apps/docs/docs/management/api/index.mdx | 7 + apps/docs/docs/management/boards/index.mdx | 171 +- apps/nextjs/next.config.ts | 4 + apps/nextjs/package.json | 2 +- .../boards/(content)/(home)/_definition.ts | 6 +- .../(content)/[name]/(board)/_definition.tsx | 6 +- .../boards/(content)/_client.module.css | 20 + .../app/[locale]/boards/(content)/_client.tsx | 164 +- .../[locale]/boards/(content)/_creator.tsx | 13 +- .../boards/(content)/_dynamic-client.tsx | 7 - .../boards/(content)/_edit-actions.tsx | 77 + .../boards/(content)/_header-actions.tsx | 211 +- .../(content)/_integration-provider.spec.ts | 54 + .../(content)/_integration-provider.tsx | 14 + .../boards/[name]/settings/_layout.module.css | 47 + .../boards/[name]/settings/_layout.tsx | 170 +- .../app/[locale]/boards/_layout-creator.tsx | 29 +- apps/nextjs/src/app/[locale]/layout.tsx | 2 + .../board/items/actions/create-item.ts | 21 +- .../board/items/actions/duplicate-item.ts | 33 +- .../board/items/actions/empty-position.ts | 2 +- .../items/actions/test/create-item.spec.ts | 43 +- .../items/actions/test/duplicate-item.spec.ts | 30 +- .../actions/test/item-move-targets.spec.ts | 123 + .../items/actions/test/mocks/board-mock.ts | 2 + .../test/mocks/category-section-mock.ts | 23 - .../test/mocks/dynamic-section-mock.ts | 14 +- .../items/actions/test/mocks/layout-mock.ts | 2 + .../components/board/items/item-content.tsx | 13 +- .../src/components/board/items/item-menu.tsx | 31 +- .../board/items/item-move-modal.tsx | 373 ++- .../board/items/item-select-modal.tsx | 6 +- .../board/items/widget-context-menu.tsx | 25 +- .../src/components/board/layout/README.md | 27 + .../components/board/layout/board-lanes.ts | 94 + .../src/components/board/layout/constants.ts | 10 + .../src/components/board/layout/geometry.ts | 54 + .../src/components/board/layout/index.ts | 6 + .../src/components/board/layout/reflow.ts | 140 + .../layout/scaled-board-canvas.module.css | 51 + .../board/layout/scaled-board-canvas.tsx | 92 + .../src/components/board/layout/semantics.ts | 56 + .../board/layout/test/board-lanes.spec.ts | 112 + .../board/layout/test/geometry.spec.ts | 31 + .../board/layout/test/reflow.spec.ts | 100 + .../layout/test/scaled-board-canvas.spec.ts | 60 + .../board/layout/test/semantics.spec.ts | 57 + .../src/components/board/layout/types.ts | 10 + .../board/sections/category-section.tsx | 65 - .../category/actions/move-category.ts | 54 - .../category/actions/remove-category.ts | 150 - .../actions/test/move-category.spec.ts | 79 - .../actions/test/remove-category.spec.ts | 145 - .../sections/category/category-actions.ts | 145 - .../sections/category/category-edit-modal.tsx | 45 - .../category/category-menu-actions.tsx | 144 - .../board/sections/category/category-menu.tsx | 128 - .../src/components/board/sections/content.tsx | 36 +- .../board/sections/dynamic-section.tsx | 90 +- .../dynamic/actions/add-dynamic-section.ts | 29 +- .../board/sections/dynamic/dynamic-actions.ts | 4 +- .../sections/dynamic/dynamic-edit-modal.tsx | 19 +- .../board/sections/dynamic/dynamic-menu.tsx | 25 +- .../board/sections/empty-section.tsx | 21 +- .../board/sections/grid/fixed-grid-item.tsx | 201 ++ .../board/sections/grid/grid-editor-loader.ts | 76 + .../board/sections/grid/grid-editor.css | 36 + .../board/sections/grid/grid-editor.tsx | 421 +++ .../board/sections/grid/grid-portal-host.tsx | 194 ++ .../sections/grid/grid-resize-handles.ts | 16 + .../sections/grid/section-grid.module.css | 77 + .../board/sections/grid/section-grid.tsx | 241 ++ .../grid/test/fixed-grid-item.spec.ts | 254 ++ .../grid/test/grid-editor-loader.spec.ts | 106 + .../grid/test/grid-import-boundary.spec.ts | 236 ++ .../grid/test/grid-resize-handles.spec.ts | 24 + .../sections/grid/use-grid-layout-actions.ts | 92 + .../sections/gridstack/gridstack-item.tsx | 62 - .../board/sections/gridstack/gridstack.tsx | 36 - .../sections/gridstack/init-gridstack.ts | 56 - .../board/sections/gridstack/use-gridstack.ts | 372 --- .../sections/section-collapse-storage.ts | 46 + .../board/sections/section-collapse.tsx | 122 + .../board/sections/section-context.ts | 8 +- .../test/section-collapse-storage.spec.ts | 53 + .../test/use-open-section-apps.spec.ts | 78 + .../board/sections/use-open-section-apps.ts | 110 + .../src/components/layout/header/button.tsx | 4 +- .../src/components/layout/viewport-hint.tsx | 28 + apps/nextjs/src/styles/gridstack.scss | 118 - e2e/board-grid.spec.ts | 946 ++++++ e2e/shared/seed-admin-user.ts | 2 + packages/api/src/router/board.ts | 254 +- .../api/src/router/section/section-router.ts | 25 +- packages/api/src/router/test/board.spec.ts | 148 + packages/api/src/router/test/section.spec.ts | 96 + .../auth/permissions/integration-provider.tsx | 25 +- packages/boards/package.json | 1 + packages/boards/src/context.tsx | 93 +- packages/boards/src/layout-selection.spec.ts | 28 + packages/boards/src/layout-selection.ts | 24 + .../0004_unify_sections_and_gutters.spec.ts | 103 + .../custom/0004_unify_sections_and_gutters.ts | 499 ++++ packages/db/migrations/custom/index.ts | 2 + .../mysql/0044_add_layout_gutters.sql | 2 + .../migrations/mysql/meta/0044_snapshot.json | 2387 ++++++++++++++++ .../db/migrations/mysql/meta/_journal.json | 7 + .../postgresql/0010_add_layout_gutters.sql | 2 + .../postgresql/meta/0010_snapshot.json | 2493 ++++++++++++++++ .../migrations/postgresql/meta/_journal.json | 7 + .../sqlite/0042_add_layout_gutters.sql | 2 + .../migrations/sqlite/meta/0042_snapshot.json | 2539 +++++++++++++++++ .../db/migrations/sqlite/meta/_journal.json | 7 + packages/db/schema/mysql.ts | 2 + packages/db/schema/postgresql.ts | 2 + packages/db/schema/sqlite.ts | 2 + packages/definitions/src/section.ts | 31 + packages/translation/src/lang/en.json | 124 +- packages/validation/src/board-layout.spec.ts | 42 + packages/validation/src/board.spec.ts | 69 + packages/validation/src/board.ts | 24 +- packages/validation/src/shared.ts | 52 +- pnpm-lock.yaml | 192 +- pnpm-workspace.yaml | 4 +- vitest.setup.ts | 2 + 127 files changed, 14934 insertions(+), 2342 deletions(-) create mode 100644 PRODUCT.md create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css delete mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.tsx create mode 100644 apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.module.css create mode 100644 apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts delete mode 100644 apps/nextjs/src/components/board/items/actions/test/mocks/category-section-mock.ts create mode 100644 apps/nextjs/src/components/board/layout/README.md create mode 100644 apps/nextjs/src/components/board/layout/board-lanes.ts create mode 100644 apps/nextjs/src/components/board/layout/constants.ts create mode 100644 apps/nextjs/src/components/board/layout/geometry.ts create mode 100644 apps/nextjs/src/components/board/layout/index.ts create mode 100644 apps/nextjs/src/components/board/layout/reflow.ts create mode 100644 apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css create mode 100644 apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx create mode 100644 apps/nextjs/src/components/board/layout/semantics.ts create mode 100644 apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts create mode 100644 apps/nextjs/src/components/board/layout/test/geometry.spec.ts create mode 100644 apps/nextjs/src/components/board/layout/test/reflow.spec.ts create mode 100644 apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts create mode 100644 apps/nextjs/src/components/board/layout/test/semantics.spec.ts create mode 100644 apps/nextjs/src/components/board/layout/types.ts delete mode 100644 apps/nextjs/src/components/board/sections/category-section.tsx delete mode 100644 apps/nextjs/src/components/board/sections/category/actions/move-category.ts delete mode 100644 apps/nextjs/src/components/board/sections/category/actions/remove-category.ts delete mode 100644 apps/nextjs/src/components/board/sections/category/actions/test/move-category.spec.ts delete mode 100644 apps/nextjs/src/components/board/sections/category/actions/test/remove-category.spec.ts delete mode 100644 apps/nextjs/src/components/board/sections/category/category-actions.ts delete mode 100644 apps/nextjs/src/components/board/sections/category/category-edit-modal.tsx delete mode 100644 apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx delete mode 100644 apps/nextjs/src/components/board/sections/category/category-menu.tsx create mode 100644 apps/nextjs/src/components/board/sections/grid/fixed-grid-item.tsx create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-editor-loader.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-editor.css create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-editor.tsx create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx create mode 100644 apps/nextjs/src/components/board/sections/grid/grid-resize-handles.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/section-grid.module.css create mode 100644 apps/nextjs/src/components/board/sections/grid/section-grid.tsx create mode 100644 apps/nextjs/src/components/board/sections/grid/test/fixed-grid-item.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/test/grid-editor-loader.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/test/grid-resize-handles.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/grid/use-grid-layout-actions.ts delete mode 100644 apps/nextjs/src/components/board/sections/gridstack/gridstack-item.tsx delete mode 100644 apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx delete mode 100644 apps/nextjs/src/components/board/sections/gridstack/init-gridstack.ts delete mode 100644 apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts create mode 100644 apps/nextjs/src/components/board/sections/section-collapse-storage.ts create mode 100644 apps/nextjs/src/components/board/sections/section-collapse.tsx create mode 100644 apps/nextjs/src/components/board/sections/test/section-collapse-storage.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/test/use-open-section-apps.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/use-open-section-apps.ts create mode 100644 apps/nextjs/src/components/layout/viewport-hint.tsx delete mode 100644 apps/nextjs/src/styles/gridstack.scss create mode 100644 e2e/board-grid.spec.ts create mode 100644 packages/api/src/router/test/section.spec.ts create mode 100644 packages/boards/src/layout-selection.spec.ts create mode 100644 packages/boards/src/layout-selection.ts create mode 100644 packages/db/migrations/custom/0004_unify_sections_and_gutters.spec.ts create mode 100644 packages/db/migrations/custom/0004_unify_sections_and_gutters.ts create mode 100644 packages/db/migrations/mysql/0044_add_layout_gutters.sql create mode 100644 packages/db/migrations/mysql/meta/0044_snapshot.json create mode 100644 packages/db/migrations/postgresql/0010_add_layout_gutters.sql create mode 100644 packages/db/migrations/postgresql/meta/0010_snapshot.json create mode 100644 packages/db/migrations/sqlite/0042_add_layout_gutters.sql create mode 100644 packages/db/migrations/sqlite/meta/0042_snapshot.json create mode 100644 packages/validation/src/board-layout.spec.ts create mode 100644 packages/validation/src/board.spec.ts diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..9ce48eec3 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,29 @@ +# Product + +## Users + +Homarr serves people and teams who run self-hosted applications and want one dependable dashboard for launching apps, monitoring services, and viewing live operational data. They use it across wall displays, desktops, tablets, and smaller devices, often returning to the same board many times per day. + +## Product Purpose + +Homarr makes self-hosted services easy to find, understand, and operate without hand-editing configuration files. A successful board loads immediately, stays visually stable, keeps widgets legible across screen sizes, and makes editing feel direct and forgiving. + +## Brand Personality + +Fast, capable, and approachable. Homarr should feel like a mature productivity tool: dense when useful, calm in view mode, and explicit about state during editing. + +## Anti-references + +Avoid cramped tile spacing, undersized text, layout shifts, decorative motion, hidden drag targets, modal-heavy positioning controls, and dashboards that require users to understand grid internals. Editing must not make the read-only experience heavier or slower. + +## Design Principles + +- Preserve the board: viewing is the primary workflow and must render before editor code is needed. +- Make spatial actions direct: moving, resizing, nesting, and using fixed sidebars should happen on the canvas with immediate feedback. +- Keep content predictable: fixed logical tile sizes preserve widget composition, typography, and controls at every viewport. +- Recover gracefully: collision handling, reflow, loading states, and persistence failures must never lose or obscure content. +- Use familiar controls: Mantine patterns, clear labels, keyboard support, and visible focus states take priority over novel decoration. + +## Accessibility & Inclusion + +Target WCAG 2.2 AA. Maintain keyboard equivalents for drag and resize operations, useful landmarks and announcements, sufficient contrast, readable control sizes, reduced-motion support, and horizontal scrolling before scaling content below an accessible minimum. diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 7ac904bf4..f5f389590 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -54,11 +54,7 @@ You must choose a username and password (both can later be changed). We enforce strict password requirements when you enter your password to protect your instance if you accidentally expose it to the internet. -{"Screenshot +{"Screenshot If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group. This group will be used to synchronize users from your provider to Homarr. @@ -116,22 +112,14 @@ The onboarding can no longer be started again. From here you can click on one of the provided links to continue your journey. We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board). -{"Screenshot +{"Screenshot ### Importing a ZIP from version before 1.0.0 This is recommended when you used Homarr before and want to re-import your data. It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0). -Export from old Homarr +Export from old Homarr **How to export and import data** @@ -149,7 +137,8 @@ It is important that you first migrate correctly using [the list of breaking cha ## Creating your first board In Homarr, you organize your apps and widgets on boards. -Boards can be organized using the interactive drag and drop. +Boards use fixed-size tiles so the same widget keeps the same proportions on every screen. +Drag, resize, and section controls are available in edit mode. Let's create your first board by navigating here: `Manage` > `Boards` > `New board`: ![](./img/manage-create-board-button.png) @@ -197,38 +186,23 @@ Here you have the following options in the header: When entering the edit mode, you'll get some additional buttons to work with: -{"Screenshot +{"Screenshot - **Add item**: Click this button to add new elements to your page. -- **Exit edit mode**: Click this btuton to save your changes and exit edit mode. +- **Exit edit mode**: Click this button to save your changes and exit edit mode. To continue, click the option "New Item": -{"Screenshot +{"Screenshot Next, you can choose what item you'd like to add. We will discuss the other options in the following sections - for now choose "App" to continue. -{"Screenshot +{"Screenshot As soon as you chose the app, the modal will close and you'll get the following item on your board: -Screenshot of the No app element on the board +Screenshot of the No app element on the board This is expected, because you haven't told Homarr what app it should show in this item. To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu. @@ -240,21 +214,27 @@ In the modal you can edit the app that you want to display: ![](./img/manage-board-choose-app.png) As soon as you save your changes, the tile on the board will refresh and display your app. -Using the small bidirectional arrow at the bottom right you can resize the element. -Start pressing with your mouse button down, move your mouse to resize and release your finger as soon your you're happy with the size. +Use the resize handle at the bottom right to resize the tile. +Press and hold the handle, move the pointer, and release it when the tile has the size you want. ![](./img/resize-item.gif) -To move your item to a different position on the board, simply hold the mouse button anywhere on the item and move with the mouse to the desired position. -As soon as you release your finger, the item will snap in place in the grid. +To move a tile in edit mode, press and hold anywhere on the tile except one of its buttons or controls, move it to the desired position, and release it. +The tile snaps to the grid and neighboring tiles move out of the way automatically. ![](./img/move-item.gif) +Every `1 × 1` tile has a logical content size of `200 × 200` pixels. +Larger tiles use multiples of that fixed unit, with a consistent gap between cells. +Homarr scales the complete board uniformly, like zooming a page. +It stops shrinking at 75% and lets you scroll horizontally instead of making text and controls too small. +It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes. + :::note -You cannot move items freely on the vertical axis. -We always align all items to the top of the page to simply organization. -To move items to the bottom, you must add more items or resize items. +Viewing a board initially uses the lightweight, non-interactive layout. +For users with edit access, Homarr prepares drag-and-drop and resize controls in the background after the board becomes interactive. +If you enter edit mode before that finishes, the existing board stays visible until the editor is ready. ::: @@ -265,8 +245,10 @@ This helps you to explore the capabilities of Homarr before you build your board ::: -Users on touch or mobile devices may not be able to drag and drop or move items. -They can use the "Move / resize item" option in the edit menu of items instead: +Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize. +Press Escape to stop. +For precise numeric controls or touch input, choose **Move / resize item** from the tile's edit menu. +The dialog can also move the tile to another section or fixed sidebar: ![](./img/manage-board-move-resize-item-menu.png) @@ -338,43 +320,39 @@ As with every change, don't forget to save your changes using the exit edit mode ![](./img/exit-edit-mode.png) -## Grouping your items +## Organizing items with sections -As the last step, let's organize your freshly created items using two different options. -You can choose either or both of them on your boards. +Sections are resizable containers for apps, widgets, and other sections. +The same section works on the main canvas and inside a fixed sidebar. +Each section can configure: -### Using dynamic sections - -Dynamic sections are a simple, resizable visual group to group items. +- Show or hide its label. +- Allow or disable collapsing. +- Show or hide **Open all in new tabs**. +- Change its border color and custom CSS classes. ![](./img/create-dynamic-section.png) -After adding a dynamic section, you can simply drag and drop items into the section: +After adding a section, drag and resize it like any other tile, then drag items into it: ![](./img/dynamic-section.gif) -### Using categories +### Labeled and collapsible sections -Categories are more advanced and complicated than dynamic sections. -To create a category, open the add item modal and choose "new category": +Open the add-content menu and choose **New section**. +Use its settings menu to add a label and enable the behavior you want. +In edit mode, move items into or out of the section directly. +In view mode, each user can collapse eligible sections without changing the saved board layout. +If **Open all in new tabs** is enabled, the section menu can open every app link it contains. -![](./img/add-category.png) +### Fixed sidebars -Every category must have a name. After you click "Add category", you will get a new item on your board: +Open the board's **Layout** settings to enable the left or right sidebar for each responsive layout. +Choose one, two, or three columns with the slider and use the preview to check the remaining canvas width. +The sidebar reserves those columns from the dashboard, so Homarr moves canvas items into the next available positions. +Sidebar content stays visible while the page scrolls and remains editable. -![](./img/category.gif) - -You can drag and drop items in to this category in the edit mode. -Categories will have a big space before and after them in the edit mode to -enable you to drag and drop items outside the category. -You can also collapse or open categories in edit mode to facilitate your editing process. - -:::note - -A current limitation of categories is, that they will always have a width of 100%. -You cannot reduce their horizontal width. - -::: +Existing categories become sections automatically, and category rails become fixed sidebars. ## Guided tours diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 2c5bd42e2..ffb746de7 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -83,3 +83,10 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Configure global board defaults such as public home boards and status behavior Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. + +### Saving dashboard layouts + +The dashboard editor saves item positions and sections as one board update. +Canvas height follows its content automatically and does not require a separate API setting. +Responsive layouts store optional left and right gutter widths from zero to three columns. +Gutter columns are reserved from the layout's total column count, and changing them reflows affected items. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..21a224f48 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,11 +9,11 @@ tags: - Layout --- -import iconColorImage from './img/icon-color.png' -import itemRadiusImage from './img/item-radius.png' -import layoutLargeImage from './img/layout-large.png' -import layoutMediumImage from './img/layout-medium.png' -import layoutSmallImage from './img/layout-small.png' +import iconColorImage from "./img/icon-color.png"; +import itemRadiusImage from "./img/item-radius.png"; +import layoutLargeImage from "./img/layout-large.png"; +import layoutMediumImage from "./img/layout-medium.png"; +import layoutSmallImage from "./img/layout-small.png"; Each dashboard is called a "board". You can create as many boards as you want. @@ -24,24 +24,30 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board + When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name + The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count -Homarr has a complex drag and drop system that can be used from the browser. -The column count defines how many horizontal columns are available to drag and drop elements to. + +The column count defines how many fixed-width cells are available in each row. +One cell contains a `200 × 200` logical-pixel tile. +Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small. ### Public + This will enable public access for everyone - regardless of whether they are logged in or not. -This can be really useful if you want to have public boards in your company or for family and fiends. +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. ## Board settings + You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -51,7 +57,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) ### General + #### Page title + This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -59,6 +67,7 @@ This is the title that will be visible at the top left in the navigation bar: This field is optional and Homarr will fall back to "Homarr" if nothing is set. #### Meta title + This is the secondary title of the page that is not directly visible in the page itself. Browser will use it to set the title of tabs: @@ -67,78 +76,153 @@ Browser will use it to set the title of tabs: Homarr will fall back to the board name and a suffix if nothing is set. #### Logo image URL + This is the URL that is used for the image at the top left. Homarr will fall back to the Homarr logo if nothing is set. #### Favicon image URL + This is the URL that is used for the favicon in the tab title. Homarr will fall back to the Homarr logo if nothing is set. ### Layout -In this section you can configure the different responsive layouts. Depending on your screens width the layout will change. +In this section you can configure the different responsive layouts. +Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly. ![](./img/layout-settings.png) -On each element the position and size of items can be changed seperately. +Every `1 × 1` tile has a `200 × 200` logical-pixel content area. +The `24` logical-pixel gap between tiles is separate from that size. +A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them. +Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller. +When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size. +On larger canvases, the complete board grows together like browser zoom. +The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size. + +The position and size of every item can be changed separately in each responsive layout. When adding a new layout the position and sizes will automatically be generated depending on the column count. -When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position. +New items use their widget's default grid size, while a new inline section starts at a useful `4 × 3` size when the layout has enough columns. +Both are placed in the first available position. + +Moving content downward in edit mode extends the canvas automatically, while view mode trims it back to the content. +Nested sections keep their own resizable bounds. #### Name -The name of the layout can be choosen freely. It is only used for the management and will not be visible on the board. +The name of the layout can be chosen freely. It is only used for management and will not be visible on the board. #### Column count -Amount of columns for this layout. Defines how many items can be placed in a row. When changed it will automatically adjust the position and size of the items. +Total number of columns for this layout. +When changed, Homarr moves items into the next available positions without overlap. + +#### Fixed sidebars + +Each responsive layout can enable a fixed left sidebar, a fixed right sidebar, or both. +Set a sidebar width to zero to disable it for that layout. +Choose a width from one to three columns with the three-step slider. +The preview shows how much space remains for the main canvas. + +Sidebar columns are reserved from the layout's total column count. +For example, a 12-column layout with a two-column left sidebar leaves ten columns for the canvas. +Enabling or widening a sidebar reflows canvas items into the remaining columns. +Disabling a sidebar moves its contents back into the next available canvas positions. + +Sidebars remain visible while the page scrolls. +Apps, widgets, and sections inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode. #### Breakpoint -Minimum width of the screen where the layout will be used. -Once the minmum of the next layout is reached the layout will change. +Minimum width of the screen where the layout will be used. +Once the minimum of the next layout is reached, the layout will change. + +#### View and edit modes + +View mode renders a static board without executing the drag-and-drop editor. +After the board is interactive, Homarr prepares the editor in the background for users who can modify it. +Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry. +Drag from anywhere on a tile except its buttons or controls. +When a tile grows or moves into occupied space, nearby tiles are compacted out of the way. +Exit edit mode to save the resulting positions. + +Keyboard users can focus a tile, press Enter or Space, then use arrow keys to move and Shift plus arrow keys to resize. +Press Escape to stop. +The **Move / resize item** action in the edit menu remains available for precise numeric or touch input. #### Preview - - - + + + + +### Sections and sidebars + +Sections can contain apps, widgets, and nested sections. +Every section uses the same model whether it is on the canvas or inside a sidebar. +Sections can be moved and resized directly in edit mode. +Expanding it creates space for its contents and pushes following items out of the way; collapsing it compacts the board. + +Each section can configure: + +- Whether its label is visible. +- Whether users may collapse it. +- Whether its menu shows **Open all in new tabs**. +- Its label, border color, and custom CSS classes. + +Existing categories and dynamic sections are converted into this unified section model. +Existing category rails become board sidebars automatically. ### Background + #### Background image URL + Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment + This defines whether the background should move when the user scrolls. #### Background image size + This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat + This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance + #### Primary color + Color that can be chosen freely to give accents to active elements. + #### Secondary color + Color that can be chosen freely to give accents. + #### Opacity + Opacity of the colors. + #### Icon color + Change the color of your bookmark / app icons. - + #### Item radius + Change the radius of your items / categories. - - + ### Custom CSS + This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -147,14 +231,16 @@ The styling will only be applied on the respective board page. ### Behaviour #### Disable app status + This will disable the status check for apps. ### Access control + With the access control, you can permit a single user or a group to your board. The users and groups can have one of these three levels of access: | **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) | -|----------------------|------------|-----------|----------|--------------------------| +| -------------------- | ---------- | --------- | -------- | ------------------------ | | View board | X | | | | | Modify board | X | X | X | | | Full access | X | X | X | X | @@ -173,18 +259,23 @@ After editing permissions, the save button below the table must be pressed. Otherwise, all changes will be discarded. ### Danger zone + #### Rename + Renaming a board will break all existing links to it permanently with no redirect. #### Board visibility + By default, boards will be private. This means only authenticated users that are authorized have access. Making a board public will expose it to everyone - including unauthenticated users. #### Delete + This will permanently delete the board. This action is irreversible and cannot be undone. ## Delete boards + The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: @@ -196,7 +287,7 @@ Both actions will execute the same deletion process. ## Duplicate boards :::note -Duplicating a board will not copy the users and groups that have access to the board +Duplicating a board will not copy the users and groups that have access to the board nor the integrations the user does not have access to. ::: @@ -206,47 +297,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board + The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: -- ``my-homarr.com`` -- ``my-homarr.com/boards`` + +- `my-homarr.com` +- `my-homarr.com/boards` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself + To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: + 1. Click on your user profile at the top right -2. Click on ``Your preferences`` -3. Change the home board in the ``home board`` dropdown and click on ``Save``. +2. Click on `Your preferences` +3. Change the home board in the `home board` dropdown and click on `Save`. ### For other users -1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` +1. Navigate to the users: `Management` > `Users` > `Manage` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` -2. Go to tab ``Settings`` -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` +2. Go to tab `Settings` +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` -2. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-global.png) +1. Navigate to the settings: `Management` > `Settings` > `Boards` +2. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. @@ -255,4 +350,4 @@ For the global home board you can only select boards that are public. ### Mobile home board Same as the home board, you can also set a mobile home board. -It will be used for devices that are detected as mobile devices. (Using User-Agent header) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 4ff31b9aa..4d3de2c83 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -68,6 +68,10 @@ const nextConfig: NextConfig = { { source: "/(.*)", // Apply CSP to all routes headers: [ + { + key: "Accept-CH", + value: "Sec-CH-Viewport-Width", + }, { key: "Content-Security-Policy", // worker-src / media-src with blob: is necessary for video.js, see https://github.com/homarr-labs/homarr/issues/3912 and https://stackoverflow.com/questions/65792855/problem-with-video-js-and-content-security-policy-csp diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 4a3e0dd3e..536464973 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -31,7 +31,6 @@ "@homarr/docker": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", "@homarr/forms-collection": "workspace:^0.1.0", - "@homarr/gridstack": "catalog:", "@homarr/icons": "workspace:^0.1.0", "@homarr/image-proxy": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", @@ -81,6 +80,7 @@ "drizzle-orm": "catalog:", "flag-icons": "catalog:", "glob": "catalog:", + "gridstack": "catalog:", "isomorphic-dompurify": "catalog:", "jotai": "catalog:", "jszip": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts index 2357d7424..6b9ba243e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts +++ b/apps/nextjs/src/app/[locale]/boards/(content)/(home)/_definition.ts @@ -1,9 +1,13 @@ +import { cache } from "react"; + import { api } from "@homarr/api/server"; import { createBoardContentPage } from "../_creator"; +const getHomeBoardCachedAsync = cache(async () => await api.board.getHomeBoard()); + export default createBoardContentPage<{ locale: string }>({ async getInitialBoardAsync() { - return await api.board.getHomeBoard(); + return await getHomeBoardCachedAsync(); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx index d3b65a5e9..a82fd00d4 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/[name]/(board)/_definition.tsx @@ -1,9 +1,13 @@ +import { cache } from "react"; + import { api } from "@homarr/api/server"; import { createBoardContentPage } from "../../_creator"; +const getBoardByNameCachedAsync = cache(async (name: string) => await api.board.getBoardByName({ name })); + export default createBoardContentPage<{ locale: string; name: string }>({ async getInitialBoardAsync({ name }) { - return await api.board.getBoardByName({ name }); + return await getBoardByNameCachedAsync(name); }, }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css new file mode 100644 index 000000000..fe7d183d2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -0,0 +1,20 @@ +.columns { + display: grid; + align-items: start; + gap: 24px; +} + +.lane { + min-width: 0; +} + +.gutter { + position: sticky; + top: 0; + align-self: start; + max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + transform: translateY(var(--board-gutter-sticky-offset, 0px)); +} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 0ba502a9a..3c79acebe 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,33 +1,167 @@ "use client"; -import { Box, Stack } from "@mantine/core"; +import { useEffect, useRef } from "react"; +import { Box } from "@mantine/core"; -import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context"; +import { useScopedI18n } from "@homarr/translation/client"; -import { BoardCategorySection } from "~/components/board/sections/category-section"; +import { + getBoardLaneColumnCount, + getInitialBoardLogicalHeight, + getLogicalTrackSize, + LOGICAL_GRID_GAP, + getRootSectionForLane, +} from "~/components/board/layout"; +import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; +import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host"; +import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse"; import { BoardBackgroundVideo } from "~/components/layout/background"; +import classes from "./_client.module.css"; + +const APP_SHELL_INLINE_PADDING = 32; + +const useFixedBoardGutters = () => { + const columnsRef = useRef(null); + + useEffect(() => { + const columns = columnsRef.current; + if (!columns) return; + + let frame = 0; + const update = () => { + frame = 0; + const columnsRect = columns.getBoundingClientRect(); + const headerBottom = document.querySelector("header")?.getBoundingClientRect().bottom ?? 0; + const stickyTop = Math.max(0, headerBottom + 16); + + for (const gutter of columns.querySelectorAll("[data-board-gutter]")) { + const rect = gutter.getBoundingClientRect(); + if (gutter.offsetWidth <= 0) continue; + + const effectiveScale = rect.width / gutter.offsetWidth; + if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue; + + const previousOffset = Number(gutter.dataset.stickyOffset ?? 0); + const uncorrectedTop = rect.top - previousOffset * effectiveScale; + const maximumTop = columnsRect.bottom - rect.height; + const targetTop = Math.min(stickyTop, maximumTop); + const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale; + + gutter.dataset.stickyOffset = String(nextOffset); + gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`); + } + }; + const scheduleUpdate = () => { + if (frame !== 0) return; + frame = window.requestAnimationFrame(update); + }; + + const resizeObserver = new ResizeObserver(scheduleUpdate); + resizeObserver.observe(columns); + document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true }); + window.addEventListener("resize", scheduleUpdate, { passive: true }); + scheduleUpdate(); + + return () => { + window.cancelAnimationFrame(frame); + resizeObserver.disconnect(); + document.removeEventListener("scroll", scheduleUpdate, { capture: true }); + window.removeEventListener("resize", scheduleUpdate); + }; + }, []); + + return columnsRef; +}; export const ClientBoard = () => { const board = useRequiredBoard(); + const t = useScopedI18n("board.landmark"); const currentLayoutId = useCurrentLayout(); + const initialViewportWidth = useInitialViewportWidth(); + const columnsRef = useFixedBoardGutters(); + const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0); + if (!currentLayout) throw new Error("Expected the board to contain a layout"); - const fullWidthSortedSections = board.sections - .filter((section) => section.kind === "empty" || section.kind === "category") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + const mainSection = getRootSectionForLane(board, "main"); + const leftSection = getRootSectionForLane(board, "left"); + const rightSection = getRootSectionForLane(board, "right"); + if (!mainSection) throw new Error("Expected the board to contain a main canvas section"); + + const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left"); + const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main"); + const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right"); + const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount] + .filter((columnCount) => columnCount > 0) + .map(getLogicalTrackSize); + const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; + const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId); + const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING); + const gridTemplateColumns = [ + leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null, + `${getLogicalTrackSize(mainColumnCount)}px`, + rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null, + ] + .filter((value) => value !== null) + .join(" "); return ( - - {fullWidthSortedSections.map((section) => - section.kind === "empty" ? ( - - ) : ( - - ), - )} - + + + +
+ {leftColumnCount > 0 && leftSection && ( + + )} + +
+ +
+ + {rightColumnCount > 0 && rightSection && ( + + )} +
+
+
+
); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 16be5c478..ee0fb4985 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -2,15 +2,11 @@ import type { Metadata } from "next"; import { Suspense } from "react"; import { TRPCError } from "@trpc/server"; -// Placed here because gridstack styles are used for board content -import "~/styles/gridstack.scss"; - import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; import superjson from "superjson"; import { queryCacheBuster } from "@homarr/api/query-cache"; import { getQueryClient } from "@homarr/api/server"; -import { IntegrationProvider } from "@homarr/auth/client"; import { auth } from "@homarr/auth/next"; import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { isNullOrWhitespace } from "@homarr/common"; @@ -25,8 +21,9 @@ import { createMetaTitle } from "~/metadata"; import { env } from "~/env"; import { createBoardLayout } from "../_layout-creator"; import type { Board, Item } from "../_types"; -import { DynamicClientBoard } from "./_dynamic-client"; +import { ClientBoard } from "./_client"; import { BoardContentHeaderActions } from "./_header-actions"; +import { BoardIntegrationProvider } from "./_integration-provider"; const logger = createLogger({ module: "createBoardContentPage" }); @@ -85,9 +82,9 @@ export const createBoardContentPage = >( - - - + + + ); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx deleted file mode 100644 index 50b0aff66..000000000 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_dynamic-client.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), { - ssr: false, -}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx new file mode 100644 index 000000000..7aa71e98e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Group, Menu } from "@mantine/core"; +import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react"; + +import { useSession } from "@homarr/auth/client"; +import { useModalAction } from "@homarr/modals"; +import { AppSelectModal } from "@homarr/modals-collection"; +import { useI18n } from "@homarr/translation/client"; + +import { useItemActions } from "~/components/board/items/item-actions"; +import { ItemSelectModal } from "~/components/board/items/item-select-modal"; +import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; +import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { HeaderButton } from "~/components/layout/header/button"; + +export default function BoardEditActions() { + return ; +} + +const AddMenu = () => { + const { data: session } = useSession(); + const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); + const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); + const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); + const { addDynamicSection } = useDynamicSectionActions(); + const { createItem } = useItemActions(); + const t = useI18n(); + + const handleSelectItem = () => { + openItemSelectModal(); + }; + + const handleSelectApp = () => { + openAppSelectModal({ + onSelect: (app) => { + createItem({ + kind: "app", + options: { appId: app.id }, + }); + }, + withCreate: session?.user.permissions.includes("app-create") ?? false, + }); + }; + + const handleAddIntegration = () => { + openIntegrationSelectModal({}); + }; + + return ( + + + + + + + + + + + } onClick={handleSelectItem}> + {t("item.action.create")} + + } onClick={handleSelectApp}> + {t("app.action.add")} + + } onClick={handleAddIntegration}> + {t("integration.action.create")} + + + } onClick={addDynamicSection}> + {t("section.dynamic.action.create")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 30c91de40..a9eb23598 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -1,49 +1,50 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { startTransition, useCallback, useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; -import { Box, Group, Menu, ScrollArea } from "@mantine/core"; +import { Box, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; -import { - IconBox, - IconBoxAlignTop, - IconChevronDown, - IconLayoutBoard, - IconPencil, - IconPencilOff, - IconPlug, - IconPlus, - IconReplace, - IconResize, - IconSettings, -} from "@tabler/icons-react"; +import { IconLayoutBoard, IconPencil, IconPencilOff, IconReplace, IconSettings } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { env } from "@homarr/common/env"; import { hotkeys } from "@homarr/definitions"; -import { useConfirmModal, useModalAction } from "@homarr/modals"; -import { AppSelectModal } from "@homarr/modals-collection"; +import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { useItemActions } from "~/components/board/items/item-actions"; -import { ItemSelectModal } from "~/components/board/items/item-select-modal"; import { useBoardPermissions } from "~/components/board/permissions/client"; -import { useCategoryActions } from "~/components/board/sections/category/category-actions"; -import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; -import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; -import { IntegrationSelectModal } from "~/components/integration/integration-select-modal"; +import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader"; import { HeaderButton } from "~/components/layout/header/button"; +import type * as EditActionsModule from "./_edit-actions"; + +let editActionsModulePromise: Promise | undefined; +const loadEditActionsAsync = () => { + editActionsModulePromise ??= import("./_edit-actions").catch((error: unknown) => { + editActionsModulePromise = undefined; + throw error; + }); + return editActionsModulePromise; +}; +const loadBoardEditorAsync = async () => + await Promise.all([ + loadGridEditorAsync(), + loadEditActionsAsync(), + import("~/components/board/items/item-menu"), + import("~/components/board/sections/dynamic/dynamic-menu"), + ]); +const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false }); export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode] = useEditMode(); const board = useRequiredBoard(); + const t = useI18n(); const { hasChangeAccess } = useBoardPermissions(board); if (!hasChangeAccess) { @@ -52,13 +53,13 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool return ( <> - {isEditMode && } + {isEditMode && } {!demoReadOnly && ( - + @@ -69,96 +70,10 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool ); }; -const AddMenu = () => { - const { data: session } = useSession(); - const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); - const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); - const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); - const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal); - const { addCategoryToEnd } = useCategoryActions(); - const { addDynamicSection } = useDynamicSectionActions(); - const { createItem } = useItemActions(); - const t = useI18n(); - - const handleAddCategory = useCallback( - () => - openCategoryEditModal( - { - category: { - id: "new", - name: "", - }, - onSuccess({ name }) { - addCategoryToEnd({ name }); - }, - submitLabel: t("section.category.create.submit"), - }, - { - title: (t) => t("section.category.create.title"), - }, - ), - [addCategoryToEnd, openCategoryEditModal, t], - ); - - const handleSelectItem = useCallback(() => { - openItemSelectModal(); - }, [openItemSelectModal]); - - const handleSelectApp = useCallback(() => { - openAppSelectModal({ - onSelect: (app) => { - createItem({ - kind: "app", - options: { appId: app.id }, - }); - }, - withCreate: session?.user.permissions.includes("app-create") ?? false, - }); - }, [openAppSelectModal, createItem]); - - const handleAddIntegration = useCallback(() => { - openIntegrationSelectModal({}); - }, [openIntegrationSelectModal]); - - return ( - - - - - - - - - - - } onClick={handleSelectItem}> - {t("item.action.create")} - - - } onClick={handleSelectApp}> - {t("app.action.add")} - - - } onClick={handleAddIntegration}> - {t("integration.action.create")} - - - - - } onClick={handleAddCategory}> - {t("section.category.action.create")} - - - } onClick={addDynamicSection}> - {t("section.dynamic.action.create")} - - - - ); -}; - const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const [isEditMode, { open, close }] = useEditMode(); + const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled"); + const [isEnteringEditMode, setIsEnteringEditMode] = useState(false); const board = useRequiredBoard(); const utils = clientApi.useUtils(); const t = useScopedI18n("board.action.edit"); @@ -185,20 +100,73 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { close(); }, [utils, board.name, close]); - const toggle = useCallback(() => { + const prepareEditorAsync = useCallback(async () => { + if (editorLoadState === "ready") return; + + setEditorLoadState("loading"); + try { + await loadBoardEditorAsync(); + setEditorLoadState("ready"); + } catch { + setEditorLoadState("error"); + throw new Error("Unable to load the board editor"); + } + }, [editorLoadState]); + + useEffect(() => { + return scheduleGridEditorWarmup(async () => { + setEditorLoadState("loading"); + try { + await loadBoardEditorAsync(); + setEditorLoadState("ready"); + } catch { + setEditorLoadState("error"); + } + }); + }, []); + + const prewarmEditor = useCallback(() => { + void prepareEditorAsync().catch(() => { + // A click or hotkey retries and reports the error. + }); + }, [prepareEditorAsync]); + + const toggle = useCallback(async () => { if (isEditMode) { if (demoReadOnly) return discardDemoChanges(); return saveBoard(board); } - open(); - }, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]); - useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); + setIsEnteringEditMode(true); + try { + await prepareEditorAsync(); + startTransition(open); + } catch { + showErrorNotification({ + title: t("notification.loadError.title"), + message: t("notification.loadError.message"), + }); + } finally { + setIsEnteringEditMode(false); + } + }, [board, demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]); + + useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]); usePreventLeaveWithDirty(isEditMode); return ( - + void toggle()} + onFocus={prewarmEditor} + onPointerEnter={prewarmEditor} + loading={isPending || isEnteringEditMode} + data-testid="board-edit-mode-toggle" + data-board-editor-preload-state={editorLoadState} + aria-busy={isPending || isEnteringEditMode} + aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")} + aria-pressed={isEditMode} + > {isEditMode ? : } @@ -207,13 +175,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const SelectBoardsMenu = () => { const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); + const t = useI18n(); return ( - + @@ -294,5 +263,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => { window.removeEventListener("popstate", handlePopState); window.removeEventListener("beforeunload", handleBeforeUnload); }; - }, [isDirty]); + }, [isDirty, openConfirmModal, router, t]); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.spec.ts new file mode 100644 index 000000000..77f18ac44 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.spec.ts @@ -0,0 +1,54 @@ +import { createElement, act } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { BoardIntegrationProvider } from "./_integration-provider"; + +const mocks = vi.hoisted(() => ({ + providedIntegrations: undefined as unknown, +})); + +vi.mock("@homarr/auth/client", async () => { + const React = await import("react"); + + return { + IntegrationProvider: ({ children, integrations }: { children: React.ReactNode; integrations: unknown }) => { + mocks.providedIntegrations = integrations; + return React.createElement(React.Fragment, null, children); + }, + }; +}); + +describe("board integration provider", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + mocks.providedIntegrations = undefined; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + test("provides the permission-filtered server result without a second client query", () => { + const initialIntegrations = [{ id: "initial" }]; + + act(() => { + root.render( + createElement( + BoardIntegrationProvider, + { initialIntegrations: initialIntegrations as never }, + createElement("span", null, "board"), + ), + ); + }); + + expect(mocks.providedIntegrations).toBe(initialIntegrations); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.tsx new file mode 100644 index 000000000..554bc0c2e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_integration-provider.tsx @@ -0,0 +1,14 @@ +"use client"; + +import type { PropsWithChildren } from "react"; + +import type { RouterOutputs } from "@homarr/api"; +import { IntegrationProvider } from "@homarr/auth/client"; + +interface Props { + initialIntegrations: RouterOutputs["integration"]["all"]; +} + +export const BoardIntegrationProvider = ({ initialIntegrations, children }: PropsWithChildren) => { + return {children}; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.module.css new file mode 100644 index 000000000..7c8bdbebd --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.module.css @@ -0,0 +1,47 @@ +.gutterSettings { + background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent); +} + +.gutterPreview { + display: grid; + gap: 6px; + width: 100%; + min-height: 112px; + padding: 8px; + overflow: hidden; + background: var(--mantine-color-body); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + transition: grid-template-columns 180ms ease-out; +} + +.previewCanvas { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + min-width: 0; +} + +.previewCanvas > div, +.previewGutter { + background: var(--mantine-color-default); + border-radius: var(--mantine-radius-sm); +} + +.previewCanvas > div:nth-child(1) { + grid-row: span 2; +} + +.previewCanvas > div:nth-child(4) { + grid-column: span 2; +} + +.previewGutter { + min-width: 12px; +} + +@media (prefers-reduced-motion: reduce) { + .gutterPreview { + transition: none; + } +} 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 97a576a30..0d5fb8f5b 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout.tsx @@ -1,6 +1,20 @@ "use client"; -import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core"; +import { + Box, + Button, + Fieldset, + Grid, + Group, + Input, + NumberInput, + Paper, + Slider, + Stack, + Switch, + Text, + TextInput, +} from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { createId } from "@homarr/common"; @@ -9,6 +23,9 @@ import { useI18n } from "@homarr/translation/client"; import { boardSaveLayoutsSchema } from "@homarr/validation/board"; import type { Board } from "../../_types"; +import classes from "./_layout.module.css"; + +const layoutSettingsSchema = boardSaveLayoutsSchema.omit({ id: true }); interface Props { board: Board; @@ -22,7 +39,7 @@ export const LayoutSettingsContent = ({ board }: Props) => { void utils.board.getHomeBoard.invalidate(); }, }); - const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), { + const form = useZodForm(layoutSettingsSchema, { initialValues: { layouts: board.layouts, }, @@ -33,7 +50,7 @@ export const LayoutSettingsContent = ({ board }: Props) => { onSubmit={form.onSubmit((values) => { saveLayouts({ id: board.id, - ...values, + layouts: values.layouts, }); })} > @@ -51,6 +68,8 @@ export const LayoutSettingsContent = ({ board }: Props) => { id: createId(), name: "", columnCount: 10, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, breakpoint: 0, }, ], @@ -70,7 +89,21 @@ export const LayoutSettingsContent = ({ board }: Props) => { - + { + const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1)); + const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1)); + form.setFieldValue(`layouts.${index}.columnCount`, columnCount); + form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left); + form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right); + }} + /> @@ -81,6 +114,15 @@ export const LayoutSettingsContent = ({ board }: Props) => { description={t("layout.field.breakpoint.description")} /> + + form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)} + onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)} + /> + {form.values.layouts.length >= 2 && ( @@ -113,3 +155,123 @@ export const LayoutSettingsContent = ({ board }: Props) => { ); }; + +interface GutterSettingsProps { + left: number; + right: number; + columnCount: number; + onLeftChange: (value: number) => void; + onRightChange: (value: number) => void; +} + +const gutterMarks = [ + { value: 1, label: "1" }, + { value: 2, label: "2" }, + { value: 3, label: "3" }, +]; + +const GutterSettings = ({ left, right, columnCount, onLeftChange, onRightChange }: GutterSettingsProps) => { + const t = useI18n(); + const maxGutterWidth = Math.min(3, Math.max(1, columnCount - 1)); + + return ( + + + + {t("layout.field.gutters.label")} + + {t("layout.field.gutters.description")} + + + + + + + + + + + + + + + + ); +}; + +const GutterControl = ({ + side, + value, + max, + onChange, +}: { + side: "left" | "right"; + value: number; + max: number; + onChange: (value: number) => void; +}) => { + const t = useI18n(); + const enabled = value > 0; + + return ( + + onChange(event.currentTarget.checked ? Math.min(2, max) : 0)} + label={t(`layout.field.gutters.${side}.label`)} + description={t(`layout.field.gutters.${side}.description`)} + /> + {enabled && ( + + mark.value <= max)} + restrictToMarks + thumbLabel={t(`layout.field.gutters.${side}.thumbLabel`)} + onChange={(nextValue) => onChange(Math.min(nextValue, max))} + /> + + )} + + ); +}; + +const GutterPreview = ({ left, right, columnCount }: { left: number; right: number; columnCount: number }) => { + const t = useI18n(); + const safeColumnCount = Math.max(1, columnCount); + const main = Math.max(1, safeColumnCount - left - right); + const tracks = [left > 0 ? `${left}fr` : null, `${main}fr`, right > 0 ? `${right}fr` : null] + .filter((track) => track !== null) + .join(" "); + + return ( + + {left > 0 && } + + + + + + + {right > 0 && } + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 3cddc6685..c6d261edd 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -1,4 +1,5 @@ import type { JSX, PropsWithChildren } from "react"; +import { cookies, headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { AppShellMain } from "@mantine/core"; import { TRPCError } from "@trpc/server"; @@ -6,6 +7,7 @@ import { TRPCError } from "@trpc/server"; import { auth } from "@homarr/auth/next"; import { BoardProvider } from "@homarr/boards/context"; import { EditModeProvider } from "@homarr/boards/edit-mode"; +import { boardViewportWidthCookieName, getLayoutIdForViewportWidth } from "@homarr/boards/layout-selection"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; @@ -62,9 +64,15 @@ export const createBoardLayout = ({ throw error; }); const colorScheme = await getCurrentColorSchemeAsync(); + const initialViewportWidth = await getInitialViewportWidthAsync(); + const initialLayoutId = getLayoutIdForViewportWidth(initialBoard.layouts, initialViewportWidth); return ( - + @@ -88,3 +96,22 @@ export const createBoardLayout = ({ return Layout; }; + +const getInitialViewportWidthAsync = async () => { + const cookieValue = (await cookies()).get(boardViewportWidthCookieName)?.value; + const cookieWidth = Number(cookieValue); + if (Number.isInteger(cookieWidth) && cookieWidth >= 200 && cookieWidth <= 10_000) { + return cookieWidth; + } + + const requestHeaders = await headers(); + const clientHintWidth = Number(requestHeaders.get("sec-ch-viewport-width")); + if (Number.isInteger(clientHintWidth) && clientHintWidth >= 200 && clientHintWidth <= 10_000) { + return clientHintWidth; + } + + const userAgent = requestHeaders.get("user-agent") ?? ""; + if (/iPad|Tablet|PlayBook|Silk/i.test(userAgent)) return 1024; + if (/Mobi|Android|iPhone|iPod|IEMobile|Opera Mini/i.test(userAgent)) return 390; + return 1440; +}; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 2388e2812..8b5c3e043 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -30,6 +30,7 @@ import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-transla import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; +import { ViewportHint } from "~/components/layout/viewport-hint"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { DayJsLoader } from "./_client-providers/dayjs-loader"; import { JotaiProvider } from "./_client-providers/jotai"; @@ -144,6 +145,7 @@ export default async function Layout(props: { + diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index d4b2cb24d..bceaaacca 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -1,10 +1,12 @@ import { getBoardLayouts } from "@homarr/boards/context"; import { createId } from "@homarr/common"; import type { Modify } from "@homarr/common/types"; +import { getRootSectionLane } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { widgetDefaultSizes } from "@homarr/definitions"; import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; +import { getBoardLaneColumnCount } from "~/components/board/layout"; import { getFirstEmptyPosition } from "./empty-position"; import { getSectionElements } from "./section-elements"; @@ -19,17 +21,21 @@ export const createItemCallback = ({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) => (previous: Board): Board => { const firstSection = previous.sections - .filter((section): section is EmptySection => section.kind === "empty") + .filter( + (section): section is EmptySection => + section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", + ) .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); if (!firstSection) return previous; + const itemLayouts = createItemLayouts(previous, firstSection, kind); const widget = { id: id ?? createId(), kind, options, - layouts: createItemLayouts(previous, firstSection, kind), + layouts: itemLayouts, integrationIds, advancedOptions: { title: null, @@ -60,9 +66,14 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); + const columnCount = boardLayout ? getBoardLaneColumnCount(boardLayout, "main") : itemSize.width; + const size = { + width: Math.min(itemSize.width, columnCount), + height: itemSize.height, + }; const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) + ? getFirstEmptyPosition(elements, columnCount, undefined, size) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -70,8 +81,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid } return { - width: itemSize.width, - height: itemSize.height, + width: size.width, + height: size.height, ...emptyPosition, sectionId: currentSection.id, layoutId, diff --git a/apps/nextjs/src/components/board/items/actions/duplicate-item.ts b/apps/nextjs/src/components/board/items/actions/duplicate-item.ts index 5d6de18c3..da0ee9c2d 100644 --- a/apps/nextjs/src/components/board/items/actions/duplicate-item.ts +++ b/apps/nextjs/src/components/board/items/actions/duplicate-item.ts @@ -1,6 +1,8 @@ import { createId } from "@homarr/common"; +import { getRootSectionLane } from "@homarr/definitions"; import type { Board, EmptySection, ItemLayout, Section } from "~/app/[locale]/boards/_types"; +import { getBoardLaneColumnCount } from "~/components/board/layout"; import { getFirstEmptyPosition } from "./empty-position"; import { getSectionElements } from "./section-elements"; @@ -15,16 +17,17 @@ export const duplicateItemCallback = if (!itemToDuplicate) return previous; const clonedItem = structuredClone(itemToDuplicate); + const clonedLayouts = clonedItem.layouts.map((layout) => ({ + ...layout, + ...getNextPosition(previous, layout), + })); return { ...previous, items: previous.items.concat({ ...clonedItem, id: createId(), - layouts: clonedItem.layouts.map((layout) => ({ - ...layout, - ...getNextPosition(previous, layout), - })), + layouts: clonedLayouts, }), }; }; @@ -42,8 +45,10 @@ const getNextPosition = (board: Board, layout: ItemLayout): { xOffset: number; y } const firstSection = board.sections - .filter((section): section is EmptySection => section.kind === "empty") - .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) + .filter( + (section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main", + ) + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); if (!firstSection) { @@ -67,18 +72,24 @@ const getEmptySectionPosition = ( layout: ItemLayout, section: Section, ): { xOffset: number; yOffset: number } | undefined => { - const boardLayout = board.layouts.find((boardLayout) => boardLayout.id === layout.layoutId); + const boardLayout = board.layouts.find((candidate) => candidate.id === layout.layoutId); if (!boardLayout) return; - const sectionElements = getSectionElements(board, { sectionId: layout.sectionId, layoutId: layout.layoutId }); + const sectionElements = getSectionElements(board, { sectionId: section.id, layoutId: layout.layoutId }); if (section.kind !== "dynamic") { - return getFirstEmptyPosition(sectionElements, boardLayout.columnCount, undefined, { + const columnCount = + section.kind === "empty" + ? getBoardLaneColumnCount(boardLayout, getRootSectionLane(section.xOffset)) + : boardLayout.columnCount; + + const size = { width: layout.width, height: layout.height, - }); + }; + return getFirstEmptyPosition(sectionElements, columnCount, undefined, size); } - const sectionLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layout.layoutId); + const sectionLayout = section.layouts.find((candidate) => candidate.layoutId === layout.layoutId); if (!sectionLayout) return; return getFirstEmptyPosition(sectionElements, sectionLayout.width, sectionLayout.height, { diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts index fd91c98d2..2f3875627 100644 --- a/apps/nextjs/src/components/board/items/actions/empty-position.ts +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -7,7 +7,7 @@ export const getFirstEmptyPosition = ( size: { width: number; height: number } = { width: 1, height: 1 }, ) => { for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) { - for (let xOffset = 0; xOffset < columnCount; xOffset++) { + for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) { const isOccupied = elements.some( (element) => element.yOffset < yOffset + size.height && diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index e2a0ff93c..f9add8dc3 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as boardContext from "@homarr/boards/context"; @@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions create-item", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("should add it to first section", () => { // Arrange const itemKind = "clock"; @@ -52,7 +56,7 @@ describe("item actions create-item", () => { ], }), ); - expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, 9999, { height: 1, width: 1 }); + expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 }); }); test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => { // Arrange @@ -94,8 +98,10 @@ describe("item actions create-item", () => { // Assert expect(result.items.length).toBe(3); - const item = result.items.find((item) => item.id !== itemInFirstSection.id && item.id !== otherItem.id); - expect(item).toEqual( + const createdItem = result.items.find( + (candidate) => candidate.id !== itemInFirstSection.id && candidate.id !== otherItem.id, + ); + expect(createdItem).toEqual( expect.objectContaining({ kind: itemKind, layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }], @@ -104,8 +110,35 @@ describe("item actions create-item", () => { expect(spy).toHaveBeenCalledWith( [expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)], layout.columnCount, - 9999, + undefined, { height: 1, width: 1 }, ); }); + + test("clamps wide defaults and places below a full automatic canvas", () => { + const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build(); + const layout = board.layouts[0]; + if (!layout) throw new Error("Expected a board layout"); + layout.id = "layout"; + layout.columnCount = 1; + board.items.push( + new ItemMockBuilder({ id: "existing" }) + .addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 }) + .build(), + ); + + const result = createItemCallback({ id: "created", kind: "mediaMissing" })(board); + const created = result.items.find((item) => item.id === "created"); + + expect(created?.layouts).toEqual([ + expect.objectContaining({ + layoutId: layout.id, + sectionId: "canvas", + width: 1, + height: 3, + xOffset: 0, + yOffset: 1, + }), + ]); + }); }); diff --git a/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts index a6dce4384..44c1c9fe0 100644 --- a/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/duplicate-item.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { duplicateItemCallback } from "../duplicate-item"; import * as emptyPositionModule from "../empty-position"; @@ -7,6 +7,10 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions duplicate-item", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("should copy it in the same section", () => { // Arrange const itemKind = "minecraftServerStatus"; @@ -61,4 +65,28 @@ describe("item actions duplicate-item", () => { }), ); }); + + test("uses the next automatic canvas row when it is the first available space", () => { + const board = new BoardMockBuilder().addEmptySection({ id: "canvas", yOffset: 0 }).build(); + const layout = board.layouts[0]; + if (!layout) throw new Error("Expected a board layout"); + layout.id = "layout"; + layout.columnCount = 1; + const currentItem = new ItemMockBuilder({ id: "source" }) + .addLayout({ layoutId: layout.id, sectionId: "canvas", width: 1, height: 1 }) + .build(); + board.items.push(currentItem); + + const result = duplicateItemCallback({ itemId: currentItem.id })(board); + const duplicated = result.items.find((item) => item.id !== currentItem.id); + + expect(duplicated?.layouts[0]).toEqual( + expect.objectContaining({ + layoutId: layout.id, + sectionId: "canvas", + xOffset: 0, + yOffset: 1, + }), + ); + }); }); diff --git a/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts b/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts new file mode 100644 index 000000000..773cbafd8 --- /dev/null +++ b/apps/nextjs/src/components/board/items/actions/test/item-move-targets.spec.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "vitest"; + +import { getMoveTargets } from "../../item-move-modal"; +import { BoardMockBuilder } from "./mocks/board-mock"; +import { DynamicSectionMockBuilder } from "./mocks/dynamic-section-mock"; +import { EmptySectionMockBuilder } from "./mocks/empty-section-mock"; +import { ItemMockBuilder } from "./mocks/item-mock"; + +describe("item move targets", () => { + test("offers the canvas, enabled gutters, and nested sections", () => { + const board = new BoardMockBuilder().build(); + const layout = board.layouts[0]; + if (!layout) throw new Error("Expected the board mock to contain a layout"); + layout.id = "layout"; + layout.columnCount = 6; + layout.leftGutterColumnCount = 1; + layout.rightGutterColumnCount = 1; + + board.sections.push( + new EmptySectionMockBuilder({ id: "canvas", xOffset: 0 }).build(), + new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(), + new EmptySectionMockBuilder({ id: "right-gutter", xOffset: 1 }).build(), + new DynamicSectionMockBuilder({ + id: "dynamic", + options: { + title: "", + customCssClasses: [], + borderColor: "", + showLabel: true, + collapsible: false, + showOpenAll: false, + }, + }) + .addLayout({ + layoutId: layout.id, + parentSectionId: "canvas", + width: 2, + height: 2, + }) + .build(), + ); + + const targets = getMoveTargets( + board, + layout.id, + { + id: "weather", + type: "item", + xOffset: 0, + yOffset: 0, + width: 1, + height: 1, + }, + { + canvas: "Dashboard canvas", + dynamicSection: "New section", + leftRail: "Left dashboard rail", + rightRail: "Right dashboard rail", + numbered: (name, index) => `${name}, destination ${index}`, + located: (name, location, index) => `${name}, ${location}, destination ${index}`, + }, + ); + + expect(targets.map((target) => target.label)).toEqual([ + "Dashboard canvas, destination 1", + "Left dashboard rail, destination 2", + "Right dashboard rail, destination 3", + "New section, destination 4", + ]); + expect(new Set(targets.map((target) => target.label))).toHaveLength(targets.length); + }); + + test("does not offer canvases that are narrower than a nested section's contents", () => { + const board = new BoardMockBuilder().build(); + const layout = board.layouts[0]; + if (!layout) throw new Error("Expected the board mock to contain a layout"); + layout.id = "layout"; + layout.columnCount = 5; + layout.leftGutterColumnCount = 2; + + const section = new DynamicSectionMockBuilder({ id: "source" }) + .addLayout({ + layoutId: layout.id, + parentSectionId: "canvas", + width: 4, + height: 2, + }) + .build(); + board.sections.push( + new EmptySectionMockBuilder({ id: "canvas" }).build(), + new EmptySectionMockBuilder({ id: "left-gutter", xOffset: -1 }).build(), + section, + ); + board.items.push( + new ItemMockBuilder({ id: "nested" }) + .addLayout({ + layoutId: layout.id, + sectionId: section.id, + xOffset: 2, + width: 2, + }) + .build(), + ); + + const sourceLayout = section.layouts[0]; + if (!sourceLayout) throw new Error("Expected the source section to contain a layout"); + const targets = getMoveTargets( + board, + layout.id, + { ...sourceLayout, id: section.id, type: "section" }, + { + canvas: "Canvas", + dynamicSection: "Section", + leftRail: "Left", + rightRail: "Right", + numbered: (name, index) => `${name} ${index}`, + located: (name, location, index) => `${name} ${location} ${index}`, + }, + ); + + expect(targets).toEqual([]); + }); +}); diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts index 9a687c2e4..79f690fac 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts @@ -45,6 +45,8 @@ export class BoardMockBuilder { id: createId(), name: "Base", columnCount: 12, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, breakpoint: 0, }, ], diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/category-section-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/category-section-mock.ts deleted file mode 100644 index a29b93c26..000000000 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/category-section-mock.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createId } from "@homarr/common"; - -import type { CategorySection } from "~/app/[locale]/boards/_types"; - -export class CategorySectionMockBuilder { - private readonly section: CategorySection; - - constructor(section?: Partial) { - this.section = { - id: createId(), - kind: "category", - xOffset: 0, - yOffset: 0, - name: "Category", - collapsed: false, - ...section, - } satisfies CategorySection; - } - - public build(): CategorySection { - return this.section; - } -} diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts index dde39bcfb..d3e188703 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/dynamic-section-mock.ts @@ -1,21 +1,23 @@ import { createId } from "@homarr/common"; +import { dynamicSectionOptionsSchema } from "@homarr/validation/shared"; import type { DynamicSection } from "~/app/[locale]/boards/_types"; +type DynamicSectionMock = Omit, "options"> & { + options?: Partial; +}; + export class DynamicSectionMockBuilder { private readonly section: DynamicSection; - constructor(section?: Partial) { + constructor(section?: DynamicSectionMock) { this.section = { id: createId(), kind: "dynamic", - options: { - title: "", - borderColor: "", - customCssClasses: [], - }, layouts: [], ...section, + collapsed: section?.collapsed ?? false, + options: dynamicSectionOptionsSchema.parse(section?.options), } satisfies DynamicSection; } diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts index 8297062f6..5fba9fc1a 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts @@ -10,6 +10,8 @@ export class LayoutMockBuilder { id: createId(), name: "Base", columnCount: 12, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, breakpoint: 0, ...layout, } satisfies Board["layouts"][0]; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index ad0f3a305..980f44834 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,5 +1,6 @@ import type { MutableRefObject } from "react"; import { useRef } from "react"; +import dynamic from "next/dynamic"; import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; @@ -17,10 +18,13 @@ import type { SectionItem } from "~/app/[locale]/boards/_types"; import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; -import { BoardItemMenu } from "./item-menu"; import { WidgetContextMenu } from "./widget-context-menu"; import { removePersistedWidgetQueries } from "./widget-query-recovery"; +const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), { + ssr: false, +}); + interface BoardItemContentProps { item: SectionItem; } @@ -41,6 +45,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { { }} fallbackRender={({ resetErrorBoundary, error }) => ( <> - + {isEditMode && } )} @@ -131,7 +138,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { (!("integrationsRequired" in definition) || definition.integrationsRequired !== false) } /> - + {isEditMode && } widgetImports[item.kind].definition, [item.kind]); - const { gridstack } = useSectionContext().refs; + const { section } = useSectionContext(); const settings = useSettings(); + const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); + const menuRightOffset = section.kind === "dynamic" ? offset + 40 : offset; // Reset error boundary on next render if item has been edited useEffect(() => { @@ -52,7 +53,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP } }, [item, resetErrorBoundary]); - if (!isEditMode || isPending) return null; + if (!isEditMode) return null; const openEditModal = () => { openModal( @@ -78,7 +79,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP }); refResetErrorBoundaryOnNextRender.current = true; }, - integrationData: (integrationData ?? []).filter( + integrationData: integrationData.filter( (integration) => "supportedIntegrations" in currentDefinition && (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), @@ -89,8 +90,8 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, { - title(t) { - return `${t("item.edit.title")} - ${t(`widget.${item.kind}.name`)}`; + title(translate) { + return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`; }, }, ); @@ -114,9 +115,9 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP radius={"xl"} pos="absolute" top={offset} - right={offset} + right={menuRightOffset} style={{ zIndex: 10 }} - aria-label={tItem("menu.label.settings")} + aria-label={tItem("menu.label.settingsFor", { name: label })} > @@ -129,8 +130,10 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP } onClick={() => { - if (!gridstack.current) return; - openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current }); + openMoveModal({ + entry: item, + sourceSectionId: section.id, + }); }} > {tItem("action.moveResize")} 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 38e362ede..78e380555 100644 --- a/apps/nextjs/src/components/board/items/item-move-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-move-modal.tsx @@ -1,94 +1,209 @@ -import { useCallback, useRef } from "react"; -import { Grid, NumberInput, Stack } from "@mantine/core"; +import { useCallback, useMemo } from "react"; +import { Grid, NumberInput, Select, Stack } from "@mantine/core"; import { z } from "zod/v4"; +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { getRootSectionLane } from "@homarr/definitions"; import { useZodForm } from "@homarr/form"; -import type { GridStack } from "@homarr/gridstack"; -import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; +import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; -import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; +import type { Board, DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types"; +import { getBoardLaneColumnCount, getLayoutRowCount, reflowVerticalLayout } from "../layout"; +import type { CommitSectionGridInput, SectionGridPlacement } from "../sections/grid/use-grid-layout-actions"; +import { useGridLayoutActions } from "../sections/grid/use-grid-layout-actions"; -interface InnerProps { - gridStack: GridStack; - item: Pick; - columnCount: number; +export type MovableEntry = Pick< + SectionItem | DynamicSectionItem, + "id" | "type" | "width" | "height" | "xOffset" | "yOffset" +>; + +export interface OpenItemMoveModalInput { + entry: MovableEntry; + sourceSectionId: string; } +interface InnerProps extends OpenItemMoveModalInput { + board: Board; + currentLayoutId: string; + commitSectionGrids: (snapshots: readonly CommitSectionGridInput[]) => void; +} + +interface MoveTarget { + id: string; + label: string; + columnCount: number; + maxRowCount: number | null; +} + +interface MoveTargetLabels { + canvas: string; + dynamicSection: string; + leftRail: string; + rightRail: string; + numbered: (name: string, index: number) => string; + located: (name: string, location: string, index: number) => string; +} + +type MoveTargetCandidate = Omit & { + name: string; + location?: string; +}; + export const ItemMoveModal = createModal(({ actions, innerProps }) => { const t = useI18n(); - const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); + const { board, commitSectionGrids, currentLayoutId, entry, sourceSectionId } = innerProps; + const minimumSize = useMemo( + () => getEntryMinimumSize(board, currentLayoutId, entry), + [board, currentLayoutId, entry], + ); + const targets = useMemo( + () => + getMoveTargets(board, currentLayoutId, entry, { + canvas: t("board.landmark.canvas"), + dynamicSection: t("section.dynamic.action.create"), + leftRail: t("board.landmark.leftRail"), + rightRail: t("board.landmark.rightRail"), + numbered: (name, index) => t("item.moveResize.target.numbered", { name, index: String(index) }), + located: (name, location, index) => + t("item.moveResize.target.located", { + name, + location, + index: String(index), + }), + }), + [board, currentLayoutId, entry, t], + ); + const targetById = useMemo(() => new Map(targets.map((target) => [target.id, target])), [targets]); + const initialTarget = targetById.get(sourceSectionId) ?? targets[0]; + + if (!initialTarget) { + throw new Error("No valid dashboard section is available for this item"); + } + const form = useZodForm( z.object({ - xOffset: z - .number() - .min(0) - .max(innerProps.columnCount - 1), - yOffset: z.number().min(0), - width: z.number().min(1).max(maxWidthRef.current), - height: z.number().min(1), + sectionId: z.string().min(1), + xOffset: z.number().int().min(0).max(32767), + yOffset: z.number().int().min(0).max(32767), + width: z.number().int().min(1).max(32767), + height: z.number().int().min(1).max(32767), }), { initialValues: { - xOffset: innerProps.item.xOffset, - yOffset: innerProps.item.yOffset, - width: innerProps.item.width, - height: innerProps.item.height, - }, - onValuesChange(values, previous) { - if (values.xOffset !== previous.xOffset) { - maxWidthRef.current = innerProps.columnCount - values.xOffset; - } + sectionId: initialTarget.id, + xOffset: entry.xOffset, + yOffset: entry.yOffset, + width: entry.width, + height: entry.height, }, }, ); + const selectedTarget = targetById.get(form.values.sectionId) ?? initialTarget; const handleSubmit = useCallback( - (values: Pick) => { - const gridItem = innerProps.gridStack - .getGridItems() - .find((item) => item.getAttribute("data-id") === innerProps.item.id); - if (!gridItem) return; - innerProps.gridStack.update(gridItem, { - h: values.height, - w: values.width, - x: values.xOffset, + (values: { sectionId: string; xOffset: number; yOffset: number; width: number; height: number }) => { + const target = targetById.get(values.sectionId); + if (!target) return; + + const width = Math.max(minimumSize.width, Math.min(values.width, target.columnCount)); + const height = Math.max(minimumSize.height, values.height); + const nextPlacement: SectionGridPlacement = { + id: entry.id, + type: entry.type, + x: Math.min(values.xOffset, target.columnCount - width), y: values.yOffset, + w: width, + h: height, + }; + const targetPlacements = getSectionPlacements(board, currentLayoutId, target.id).filter( + (placement) => placement.id !== entry.id, + ); + const resolvedTarget = reflowVerticalLayout([...targetPlacements, nextPlacement], { + columnCount: target.columnCount, + pinnedItemId: entry.id, }); + if (target.maxRowCount !== null && getLayoutRowCount(resolvedTarget) > target.maxRowCount) { + form.setFieldError("height", t("item.moveResize.keyboard.boundary")); + return; + } + + const snapshots = + sourceSectionId === target.id + ? [{ layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }] + : [ + { + layoutId: currentLayoutId, + sectionId: sourceSectionId, + placements: reflowVerticalLayout( + getSectionPlacements(board, currentLayoutId, sourceSectionId).filter( + (placement) => placement.id !== entry.id, + ), + { + columnCount: getSectionColumnCount(board, currentLayoutId, sourceSectionId), + }, + ), + }, + { layoutId: currentLayoutId, sectionId: target.id, placements: resolvedTarget }, + ]; + + commitSectionGrids(snapshots); actions.closeModal(); + focusMovedEntry(entry.id, target.id); }, - [actions, innerProps.gridStack, innerProps.item.id], + [actions, board, commitSectionGrids, currentLayoutId, entry, form, minimumSize, sourceSectionId, t, targetById], ); return (
+