Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc24e9091 | ||
|
|
9256a432f9 | ||
|
|
bed69c7a92 | ||
|
|
6f62f3ca43 | ||
|
|
f23d197a35 | ||
|
|
5887735bd5 | ||
|
|
6db134cf15 | ||
|
|
ecc1009e23 |
No files matched your search
@@ -62,6 +62,11 @@ Each board has exactly one Mobile and one Base layout. Mobile uses breakpoint `0
|
|||||||
layouts can use any other breakpoint, and Homarr selects the highest breakpoint that fits the viewport. Preserve layout
|
layouts can use any other breakpoint, and Homarr selects the highest breakpoint that fits the viewport. Preserve layout
|
||||||
roles and use the canonical layouts returned by save operations, including generated IDs.
|
roles and use the canonical layouts returned by save operations, including generated IDs.
|
||||||
|
|
||||||
|
Board settings expose `fixedScaling` and `fixedItemSize`. The default fitted mode uses `fixedScaling: false`; setting it
|
||||||
|
to `true` keeps each `1 × 1` tile at `fixedItemSize` pixels, which must be an integer from `40` through `200`. The
|
||||||
|
configured size is retained while fixed scaling is disabled. Layout `columnCount` remains the maximum total column count,
|
||||||
|
including fixed sidebars.
|
||||||
|
|
||||||
Creating or changing an integration-backed widget requires use access to each newly selected integration.
|
Creating or changing an integration-backed widget requires use access to each newly selected integration.
|
||||||
|
|
||||||
## Docker targets
|
## Docker targets
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ Mobile and Base are protected layouts. New boards use three Mobile columns; Base
|
|||||||
creation. Additional layouts use unique breakpoints. Homarr selects the layout with the highest breakpoint that fits
|
creation. Additional layouts use unique breakpoints. Homarr selects the layout with the highest breakpoint that fits
|
||||||
the current viewport.
|
the current viewport.
|
||||||
|
|
||||||
Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scales the complete layout to the available
|
By default, Homarr fits the complete fixed-proportion tile grid to the available width. Enable **Fixed scaling** in the
|
||||||
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
|
board's Layout settings to render each top-level `1 × 1` tile at the configured CSS-pixel size instead. A fixed dashboard is
|
||||||
current Base layout.
|
centred when it fits and scrolls both horizontally and vertically when it does not, without adding extra space above
|
||||||
|
the board.
|
||||||
|
|
||||||
|
The column count is the maximum total for that layout, including any fixed sidebars. For example, a Base layout with
|
||||||
|
five columns and no sidebars remains a centred five-column dashboard on an ultrawide screen. **Reset from Base**
|
||||||
|
regenerates a Mobile or custom layout from the current Base layout.
|
||||||
|
|
||||||
### Containers and rails
|
### Containers and rails
|
||||||
|
|
||||||
@@ -41,8 +46,8 @@ scrolls and store their contents separately for each layout.
|
|||||||
|
|
||||||
## Edit a board
|
## Edit a board
|
||||||
|
|
||||||
Enter edit mode from the board header to add, move, resize, configure, or remove content. Save the board to persist the
|
Enter edit mode from the board header or account menu to add, move, resize, configure, or remove content. The account
|
||||||
layout.
|
menu also works when the header is hidden, using the floating user button. Choose **Save board** to persist changes.
|
||||||
|
|
||||||
- Drag from an item's inactive content area; Containers use their grip.
|
- Drag from an item's inactive content area; Containers use their grip.
|
||||||
- Hold `Ctrl` on Windows/Linux or `Cmd` on macOS to select several items.
|
- Hold `Ctrl` on Windows/Linux or `Cmd` on macOS to select several items.
|
||||||
@@ -51,19 +56,40 @@ layout.
|
|||||||
- Press Escape during a pointer or touch interaction to restore the item to its previous position.
|
- Press Escape during a pointer or touch interaction to restore the item to its previous position.
|
||||||
|
|
||||||
The add-content menu can create an app, integration, widget, or Container without leaving the board.
|
The add-content menu can create an app, integration, widget, or Container without leaving the board.
|
||||||
|
Right-click empty board space to open this menu at the cursor. Selecting an action enters edit mode automatically.
|
||||||
|
These actions require board editing permission; creating integrations also requires integration creation permission.
|
||||||
|
Right-clicking existing content keeps its own menu. Hold Shift while right-clicking to use the browser menu.
|
||||||
When adding apps, click one app to add it immediately, or choose **Select multiple** to add several at once. `Ctrl`/`Cmd`
|
When adding apps, click one app to add it immediately, or choose **Select multiple** to add several at once. `Ctrl`/`Cmd`
|
||||||
click remains available as a shortcut.
|
click remains available as a shortcut.
|
||||||
|
|
||||||
## Board settings
|
## Board settings
|
||||||
|
|
||||||
Board settings cover:
|
Settings are organized into tabs, with one **Save changes** action for the whole board. Switching tabs retains your
|
||||||
|
draft; **Discard** restores the saved settings.
|
||||||
|
|
||||||
- **General:** page title, browser title, logo, and favicon.
|
- **Layout:** tile sizing, responsive layouts, breakpoints, maximum columns, and rails.
|
||||||
- **Layout:** responsive layouts, breakpoints, columns, and rails.
|
- **Appearance:** colors, opacity, icon color, item radius, and background image settings beside a live layout preview.
|
||||||
- **Background:** image, attachment, size, and repeat behavior.
|
- **General:** page title, browser title, logo, favicon, and app status.
|
||||||
- **Appearance:** colors, opacity, icon color, and item radius.
|
- **Advanced:** custom CSS applied only to this board.
|
||||||
- **Custom CSS:** styles applied only to the current board.
|
- **Access control:** permissions, visibility, renaming, and deletion (requires full access).
|
||||||
- **Behaviour:** app status and widget context-menu behavior.
|
|
||||||
|
### Try a layout before saving
|
||||||
|
|
||||||
|
Choose **Fixed scaling** or **Fit to screen** under **Tile sizing**. Fixed sizing accepts **40–200 px**; its square
|
||||||
|
sample is shown at half size with the configured dimensions underneath. The size is retained when you switch modes.
|
||||||
|
|
||||||
|
Select a layout to edit its columns, breakpoint, and side rails beside the compact **Board preview**. It uses the
|
||||||
|
familiar Boards thumbnail, with the board's actual grid proportions and rails aligned beside the main canvas.
|
||||||
|
The indicated screen width represents the selected layout. The miniature stays contained, centres fixed grids that
|
||||||
|
fit, and scrolls when needed. Changes to sizing and appearance appear immediately, before saving.
|
||||||
|
|
||||||
|
Choose **Item radius** using the same visual corner-style picker as server branding. Selecting a card updates the
|
||||||
|
board preview immediately; save the board settings to apply it.
|
||||||
|
|
||||||
|
In fixed mode, browser `Cmd`/`Ctrl` + `+` or `-` zoom works naturally: a 200 × 200 CSS-pixel tile keeps those dimensions
|
||||||
|
while its physical screen size changes with zoom. Homarr does not shrink the tile to cancel the browser's zoom.
|
||||||
|
Your live board can still select a different saved layout when its viewport crosses a breakpoint. Nested Containers
|
||||||
|
fit their inner grids into their own frames; the configured tile size describes the top-level board grid.
|
||||||
|
|
||||||
See [Styling](/docs/advanced/styling) for board and global CSS.
|
See [Styling](/docs/advanced/styling) for board and global CSS.
|
||||||
|
|
||||||
|
|||||||
+8
@@ -2151,6 +2151,14 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"fixedScaling": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"fixedItemSize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 40,
|
||||||
|
"maximum": 200
|
||||||
|
},
|
||||||
"disableStatus": {
|
"disableStatus": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.columns {
|
.columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lane {
|
.lane {
|
||||||
@@ -27,3 +26,10 @@
|
|||||||
outline-offset: -0.0625rem;
|
outline-offset: -0.0625rem;
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-canvas-scaling="fixed"]) .gutter {
|
||||||
|
--board-gutter-sticky-top: 0px;
|
||||||
|
--board-gutter-viewport-height: calc(
|
||||||
|
var(--board-canvas-fixed-viewport-height) * var(--board-canvas-inverse-scale, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,15 +13,16 @@ import { FloatingTip } from "@homarr/ui";
|
|||||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||||
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
|
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
|
||||||
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
import { BoardEmptyState } from "~/components/board/board-empty-state";
|
||||||
|
import { BoardContextMenu } from "~/components/board/board-context-menu";
|
||||||
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||||
import {
|
import {
|
||||||
getBoardLaneColumnCount,
|
getBoardLaneColumnCount,
|
||||||
getInitialBoardLogicalHeight,
|
getInitialBoardLogicalHeight,
|
||||||
getLogicalGridSize,
|
|
||||||
LOGICAL_GRID_GAP,
|
|
||||||
getRootSectionForLane,
|
getRootSectionForLane,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||||
|
import { BOARD_LANE_GAP, getBoardCanvasGeometry } from "~/components/board/layout/canvas-geometry";
|
||||||
|
import { getBoardScaling } from "~/components/board/layout/scaling";
|
||||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||||
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
|
||||||
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
|
||||||
@@ -60,99 +61,91 @@ export const ClientBoard = () => {
|
|||||||
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
|
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
|
||||||
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
|
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
|
||||||
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
|
||||||
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
|
const { width: logicalWidth, gridTemplateColumns } = getBoardCanvasGeometry(currentLayout);
|
||||||
.filter((columnCount) => columnCount > 0)
|
|
||||||
.map(getLogicalGridSize);
|
|
||||||
const logicalWidth =
|
|
||||||
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
|
|
||||||
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
|
||||||
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
|
||||||
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
|
||||||
const gridTemplateColumns = [
|
|
||||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
|
||||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
|
||||||
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
|
|
||||||
]
|
|
||||||
.filter((value) => value !== null)
|
|
||||||
.join(" ");
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<BoardSelectionGridProvider>
|
<BoardSelectionGridProvider>
|
||||||
<BoardAdvancedFocusProvider>
|
<BoardAdvancedFocusProvider>
|
||||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
<BoardContextMenu>
|
||||||
<BoardAppsSpotlightRegistrar />
|
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||||
<BoardBackgroundVideo />
|
<BoardAppsSpotlightRegistrar />
|
||||||
<BoardEmptyState />
|
<BoardBackgroundVideo />
|
||||||
<BoardSetupChecklist />
|
<BoardEmptyState />
|
||||||
<BoardSectionCollapseProvider>
|
<BoardSetupChecklist />
|
||||||
<BoardGridPortalHost>
|
<BoardSectionCollapseProvider>
|
||||||
<ScaledBoardCanvas
|
<BoardGridPortalHost>
|
||||||
logicalWidth={logicalWidth}
|
<ScaledBoardCanvas
|
||||||
initialLogicalHeight={initialLogicalHeight}
|
logicalWidth={logicalWidth}
|
||||||
initialAvailableWidth={initialAvailableWidth}
|
initialLogicalHeight={initialLogicalHeight}
|
||||||
label={board.name}
|
initialAvailableWidth={initialAvailableWidth}
|
||||||
>
|
scaling={getBoardScaling(board)}
|
||||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
label={board.name}
|
||||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
>
|
||||||
{leftColumnCount > 0 && leftSection && (
|
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||||
<aside
|
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
|
||||||
className={`${classes.lane} ${classes.gutter}`}
|
{leftColumnCount > 0 && leftSection && (
|
||||||
aria-label={t("leftRail")}
|
<aside
|
||||||
data-board-gutter="left"
|
className={`${classes.lane} ${classes.gutter}`}
|
||||||
data-board-editing={isEditMode ? "true" : undefined}
|
aria-label={t("leftRail")}
|
||||||
>
|
data-board-gutter="left"
|
||||||
<BoardEmptySection
|
data-board-editing={isEditMode ? "true" : undefined}
|
||||||
key={`${currentLayoutId}-${leftSection.id}`}
|
>
|
||||||
section={leftSection}
|
<BoardEmptySection
|
||||||
columnCount={leftColumnCount}
|
key={`${currentLayoutId}-${leftSection.id}`}
|
||||||
requestedRowCount={0}
|
section={leftSection}
|
||||||
railPlacement="left"
|
columnCount={leftColumnCount}
|
||||||
/>
|
requestedRowCount={0}
|
||||||
</aside>
|
railPlacement="left"
|
||||||
)}
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className={classes.lane} aria-label={t("canvas")}>
|
<section className={classes.lane} aria-label={t("canvas")}>
|
||||||
<BoardEmptySection
|
|
||||||
key={`${currentLayoutId}-${mainSection.id}`}
|
|
||||||
section={mainSection}
|
|
||||||
columnCount={mainColumnCount}
|
|
||||||
requestedRowCount={0}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{rightColumnCount > 0 && rightSection && (
|
|
||||||
<aside
|
|
||||||
className={`${classes.lane} ${classes.gutter}`}
|
|
||||||
aria-label={t("rightRail")}
|
|
||||||
data-board-gutter="right"
|
|
||||||
data-board-editing={isEditMode ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<BoardEmptySection
|
<BoardEmptySection
|
||||||
key={`${currentLayoutId}-${rightSection.id}`}
|
key={`${currentLayoutId}-${mainSection.id}`}
|
||||||
section={rightSection}
|
section={mainSection}
|
||||||
columnCount={rightColumnCount}
|
columnCount={mainColumnCount}
|
||||||
requestedRowCount={0}
|
requestedRowCount={0}
|
||||||
railPlacement="right"
|
|
||||||
/>
|
/>
|
||||||
</aside>
|
</section>
|
||||||
)}
|
|
||||||
</div>
|
{rightColumnCount > 0 && rightSection && (
|
||||||
</BoardGridEditorBoundary>
|
<aside
|
||||||
</ScaledBoardCanvas>
|
className={`${classes.lane} ${classes.gutter}`}
|
||||||
</BoardGridPortalHost>
|
aria-label={t("rightRail")}
|
||||||
</BoardSectionCollapseProvider>
|
data-board-gutter="right"
|
||||||
<BoardSelectionToolbar />
|
data-board-editing={isEditMode ? "true" : undefined}
|
||||||
<FloatingTip
|
>
|
||||||
opened={isEditMode}
|
<BoardEmptySection
|
||||||
showDelay={2_000}
|
key={`${currentLayoutId}-${rightSection.id}`}
|
||||||
dismissAfter={3_000}
|
section={rightSection}
|
||||||
transitionDuration={200}
|
columnCount={rightColumnCount}
|
||||||
closable={false}
|
requestedRowCount={0}
|
||||||
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
railPlacement="right"
|
||||||
>
|
/>
|
||||||
{tTips("multiSelectApps")}
|
</aside>
|
||||||
</FloatingTip>
|
)}
|
||||||
</Box>
|
</div>
|
||||||
|
</BoardGridEditorBoundary>
|
||||||
|
</ScaledBoardCanvas>
|
||||||
|
</BoardGridPortalHost>
|
||||||
|
</BoardSectionCollapseProvider>
|
||||||
|
<BoardSelectionToolbar />
|
||||||
|
<FloatingTip
|
||||||
|
opened={isEditMode}
|
||||||
|
showDelay={2_000}
|
||||||
|
dismissAfter={3_000}
|
||||||
|
transitionDuration={200}
|
||||||
|
closable={false}
|
||||||
|
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
|
||||||
|
>
|
||||||
|
{tTips("multiSelectApps")}
|
||||||
|
</FloatingTip>
|
||||||
|
</Box>
|
||||||
|
</BoardContextMenu>
|
||||||
</BoardAdvancedFocusProvider>
|
</BoardAdvancedFocusProvider>
|
||||||
</BoardSelectionGridProvider>
|
</BoardSelectionGridProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
}: Props<TParams>) => {
|
}: Props<TParams>) => {
|
||||||
return {
|
return {
|
||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
headerBoardEditAction: <BoardContentEditAction />,
|
||||||
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
withTour: true,
|
withTour: true,
|
||||||
|
|||||||
@@ -3,18 +3,14 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Group, Menu } from "@mantine/core";
|
import { Group, Menu } from "@mantine/core";
|
||||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
import { IconChevronDown, IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useSession } from "@homarr/auth/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { AppSelectModal } from "@homarr/modals-collection";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { useItemActions } from "~/components/board/items/item-actions";
|
|
||||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
import { BoardAddMenuItems } from "~/components/board/board-add-menu-items";
|
||||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
|
|
||||||
export default function BoardEditActions() {
|
export default function BoardEditActions() {
|
||||||
@@ -22,16 +18,9 @@ export default function BoardEditActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AddMenu = () => {
|
const AddMenu = () => {
|
||||||
const { data: session } = useSession();
|
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
|
||||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
|
||||||
const { addContainer } = useContainerActions();
|
|
||||||
const { createItem } = useItemActions();
|
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tIntegration = useI18n("integration");
|
|
||||||
const tContainer = useI18n("section.container");
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -48,34 +37,6 @@ const AddMenu = () => {
|
|||||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||||
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
|
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
|
||||||
|
|
||||||
const handleSelectItem = () => {
|
|
||||||
openItemSelectModal({ boardId: board.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectApp = () => {
|
|
||||||
openAppSelectModal({
|
|
||||||
onSelect: (app) => {
|
|
||||||
createItem({
|
|
||||||
kind: "app",
|
|
||||||
options: { appId: app.id },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSelectMany: (apps) => {
|
|
||||||
for (const app of apps) {
|
|
||||||
createItem({
|
|
||||||
kind: "app",
|
|
||||||
options: { appId: app.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddIntegration = () => {
|
|
||||||
openIntegrationSelectModal({ completionBoardId: board.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu position="bottom-end">
|
<Menu position="bottom-end">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -87,21 +48,7 @@ const AddMenu = () => {
|
|||||||
</HeaderButton>
|
</HeaderButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
<BoardAddMenuItems />
|
||||||
{tBoard("emptyState.addWidget")}
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
|
||||||
{tBoard("emptyState.addApp")}
|
|
||||||
</Menu.Item>
|
|
||||||
{session?.user.permissions.includes("integration-create") && (
|
|
||||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
|
||||||
{tIntegration("action.create")}
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
<Menu.Divider />
|
|
||||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
|
||||||
{tContainer("action.create")}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/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 } from "@homarr/modals";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
|
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
||||||
|
import type * as EditActionsModule from "./_edit-actions";
|
||||||
|
import { BoardEditingContext } from "~/components/board/board-editing-context";
|
||||||
|
|
||||||
|
let editActionsModulePromise: Promise<typeof EditActionsModule> | 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/container/container-menu"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type BoardEditingControls = ReturnType<typeof useBoardEditingController>;
|
||||||
|
|
||||||
|
export const BoardEditingProvider = ({
|
||||||
|
children,
|
||||||
|
enabled,
|
||||||
|
demoReadOnly,
|
||||||
|
}: PropsWithChildren<{ enabled: boolean; demoReadOnly: boolean }>) => {
|
||||||
|
if (!enabled) return children;
|
||||||
|
return <ActiveBoardEditingProvider demoReadOnly={demoReadOnly}>{children}</ActiveBoardEditingProvider>;
|
||||||
|
};
|
||||||
|
const ActiveBoardEditingProvider = ({ children, demoReadOnly }: PropsWithChildren<{ demoReadOnly: boolean }>) => {
|
||||||
|
const controller = useBoardEditingController(demoReadOnly);
|
||||||
|
return <BoardEditingContext.Provider value={controller}>{children}</BoardEditingContext.Provider>;
|
||||||
|
};
|
||||||
|
const useBoardEditingController = (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 { hasChangeAccess } = useBoardPermissions(board);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const tBoardSetting = useI18n("board.setting");
|
||||||
|
const t = useI18n("board.action.edit");
|
||||||
|
const returnToLayoutSettings =
|
||||||
|
isEditMode &&
|
||||||
|
searchParams.get("edit") === "true" &&
|
||||||
|
searchParams.get("layout") !== null &&
|
||||||
|
searchParams.get("returnTo") === "settings";
|
||||||
|
const latestBoardRef = useRef(board);
|
||||||
|
|
||||||
|
latestBoardRef.current = board;
|
||||||
|
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
||||||
|
onMutate(boardToSave) {
|
||||||
|
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showSuccessNotification({
|
||||||
|
title: tCommon("notification.update.success"),
|
||||||
|
message: t("notification.success.message"),
|
||||||
|
});
|
||||||
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
|
void utils.widget.customApi.getData.invalidate();
|
||||||
|
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||||
|
close();
|
||||||
|
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
title: tCommon("notification.update.error"),
|
||||||
|
message: t("notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const discardDemoChanges = useCallback(() => {
|
||||||
|
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
|
close();
|
||||||
|
}, [utils, board.name, close]);
|
||||||
|
|
||||||
|
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 enter = useCallback(async () => {
|
||||||
|
if (!hasChangeAccess || isPending || isEnteringEditMode) return false;
|
||||||
|
if (isEditMode) return true;
|
||||||
|
setIsEnteringEditMode(true);
|
||||||
|
try {
|
||||||
|
await prepareEditorAsync();
|
||||||
|
open();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
showErrorNotification({
|
||||||
|
title: t("notification.loadError.title"),
|
||||||
|
message: t("notification.loadError.message"),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsEnteringEditMode(false);
|
||||||
|
}
|
||||||
|
}, [hasChangeAccess, isPending, isEnteringEditMode, isEditMode, open, prepareEditorAsync, t]);
|
||||||
|
|
||||||
|
const toggle = useCallback(async () => {
|
||||||
|
if (!hasChangeAccess || isPending || isEnteringEditMode) return;
|
||||||
|
if (!isEditMode) return enter();
|
||||||
|
if (demoReadOnly) return discardDemoChanges();
|
||||||
|
saveBoard(latestBoardRef.current);
|
||||||
|
}, [hasChangeAccess, isPending, isEnteringEditMode, isEditMode, enter, demoReadOnly, discardDemoChanges, saveBoard]);
|
||||||
|
|
||||||
|
const cancelLayoutEdit = useCallback(() => {
|
||||||
|
openConfirmModal({
|
||||||
|
title: tBoardSetting("section.layout.edit.cancel.title"),
|
||||||
|
children: tBoardSetting("section.layout.edit.cancel.message"),
|
||||||
|
confirmProps: { children: tCommon("action.discard"), color: "red" },
|
||||||
|
onConfirm() {
|
||||||
|
void (async () => {
|
||||||
|
await utils.board.getBoardByName.invalidate({ name: board.name });
|
||||||
|
close();
|
||||||
|
router.push(`/boards/${board.name}/settings?tab=layout`);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
|
||||||
|
|
||||||
|
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
||||||
|
usePreventLeaveWithDirty(isEditMode && hasChangeAccess);
|
||||||
|
|
||||||
|
let label = t("enter");
|
||||||
|
if (isEditMode) label = t("save");
|
||||||
|
if (isEditMode && demoReadOnly) label = t("exit");
|
||||||
|
return {
|
||||||
|
isEditMode,
|
||||||
|
hasChangeAccess,
|
||||||
|
label,
|
||||||
|
toggle,
|
||||||
|
enter,
|
||||||
|
prewarmEditor,
|
||||||
|
editorLoadState,
|
||||||
|
isPending,
|
||||||
|
isEnteringEditMode,
|
||||||
|
returnToLayoutSettings,
|
||||||
|
cancelLayoutEdit,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const anchorSelector = "a[href]:not([target='_blank'])";
|
||||||
|
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||||
|
const tBoard = useI18n("board");
|
||||||
|
const tCommon = useI18n("common");
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty) return;
|
||||||
|
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||||
|
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
openConfirmModal({
|
||||||
|
title: tBoard("action.edit.confirmLeave.title"),
|
||||||
|
children: tBoard("action.edit.confirmLeave.message"),
|
||||||
|
onConfirm() {
|
||||||
|
router.push(target.href);
|
||||||
|
},
|
||||||
|
confirmProps: {
|
||||||
|
children: tCommon("action.discard"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopState = (event: Event) => {
|
||||||
|
window.history.pushState(null, document.title, window.location.href);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (env.NODE_ENV === "development") return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", handleClick, true);
|
||||||
|
window.addEventListener("popstate", handlePopState);
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleClick, true);
|
||||||
|
window.removeEventListener("popstate", handlePopState);
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
};
|
||||||
|
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
|
||||||
|
};
|
||||||
@@ -1,45 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
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 } from "@homarr/modals";
|
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||||
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
|
import { useOptionalBoardEditing } from "~/components/board/board-editing-context";
|
||||||
import { HeaderButton } from "~/components/layout/header/button";
|
import { HeaderButton } from "~/components/layout/header/button";
|
||||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||||
import type * as EditActionsModule from "./_edit-actions";
|
const BoardEditActions = dynamic(() => import("./_edit-actions"), { ssr: false });
|
||||||
|
export const BoardContentEditAction = () => {
|
||||||
let editActionsModulePromise: Promise<typeof EditActionsModule> | 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/container/container-menu"),
|
|
||||||
]);
|
|
||||||
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
|
|
||||||
|
|
||||||
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
@@ -49,7 +19,7 @@ export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditMode && <BoardEditActions />}
|
{isEditMode && <BoardEditActions />}
|
||||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
<EditModeMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -70,127 +40,26 @@ export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boo
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
const EditModeMenu = () => {
|
||||||
const [isEditMode, { open, close }] = useEditMode();
|
const editing = useOptionalBoardEditing();
|
||||||
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
|
|
||||||
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
const utils = clientApi.useUtils();
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const tBoardSetting = useI18n("board.setting");
|
const tBoardSetting = useI18n("board.setting");
|
||||||
const t = useI18n("board.action.edit");
|
if (!editing) return null;
|
||||||
const returnToLayoutSettings =
|
const {
|
||||||
isEditMode &&
|
isEditMode,
|
||||||
searchParams.get("edit") === "true" &&
|
toggle,
|
||||||
searchParams.get("layout") !== null &&
|
prewarmEditor,
|
||||||
searchParams.get("returnTo") === "settings";
|
editorLoadState,
|
||||||
const latestBoardRef = useRef(board);
|
isPending,
|
||||||
|
isEnteringEditMode,
|
||||||
latestBoardRef.current = board;
|
returnToLayoutSettings,
|
||||||
const { mutate: saveBoard, isPending } = clientApi.board.saveBoard.useMutation({
|
cancelLayoutEdit,
|
||||||
onMutate(boardToSave) {
|
} = editing;
|
||||||
boardToSave.items.forEach(({ id }) => notifications.hide(`board-item-created:${id}`));
|
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
showSuccessNotification({
|
|
||||||
title: tCommon("notification.update.success"),
|
|
||||||
message: t("notification.success.message"),
|
|
||||||
});
|
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
|
||||||
void utils.widget.customApi.getData.invalidate();
|
|
||||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
|
||||||
close();
|
|
||||||
if (returnToLayoutSettings) router.push(`/boards/${board.name}/settings?tab=layout`);
|
|
||||||
},
|
|
||||||
onError() {
|
|
||||||
showErrorNotification({
|
|
||||||
title: tCommon("notification.update.error"),
|
|
||||||
message: t("notification.error.message"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const discardDemoChanges = useCallback(() => {
|
|
||||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
|
||||||
close();
|
|
||||||
}, [utils, board.name, close]);
|
|
||||||
|
|
||||||
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(latestBoardRef.current);
|
|
||||||
}
|
|
||||||
setIsEnteringEditMode(true);
|
|
||||||
try {
|
|
||||||
await prepareEditorAsync();
|
|
||||||
open();
|
|
||||||
} catch {
|
|
||||||
showErrorNotification({
|
|
||||||
title: t("notification.loadError.title"),
|
|
||||||
message: t("notification.loadError.message"),
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsEnteringEditMode(false);
|
|
||||||
}
|
|
||||||
}, [demoReadOnly, discardDemoChanges, isEditMode, open, prepareEditorAsync, saveBoard, t]);
|
|
||||||
|
|
||||||
const cancelLayoutEdit = useCallback(() => {
|
|
||||||
openConfirmModal({
|
|
||||||
title: tBoardSetting("section.layout.edit.cancel.title"),
|
|
||||||
children: tBoardSetting("section.layout.edit.cancel.message"),
|
|
||||||
confirmProps: { children: tCommon("action.discard"), color: "red" },
|
|
||||||
onConfirm() {
|
|
||||||
void (async () => {
|
|
||||||
await utils.board.getBoardByName.invalidate({ name: board.name });
|
|
||||||
close();
|
|
||||||
router.push(`/boards/${board.name}/settings?tab=layout`);
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
|
|
||||||
|
|
||||||
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
|
|
||||||
usePreventLeaveWithDirty(isEditMode);
|
|
||||||
|
|
||||||
if (returnToLayoutSettings) {
|
if (returnToLayoutSettings) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
onClick={() => saveBoard(board)}
|
onClick={() => void toggle()}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
aria-label={tBoardSetting("section.layout.edit.save")}
|
aria-label={tBoardSetting("section.layout.edit.save")}
|
||||||
>
|
>
|
||||||
@@ -217,7 +86,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
data-testid="board-edit-mode-toggle"
|
data-testid="board-edit-mode-toggle"
|
||||||
data-board-editor-preload-state={editorLoadState}
|
data-board-editor-preload-state={editorLoadState}
|
||||||
aria-busy={isPending || isEnteringEditMode}
|
aria-busy={isPending || isEnteringEditMode}
|
||||||
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
|
aria-label={editing.label}
|
||||||
aria-pressed={isEditMode}
|
aria-pressed={isEditMode}
|
||||||
>
|
>
|
||||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||||
@@ -225,57 +94,3 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
|||||||
</TourTarget>
|
</TourTarget>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const anchorSelector = "a[href]:not([target='_blank'])";
|
|
||||||
const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
|
||||||
const tBoard = useI18n("board");
|
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDirty) return;
|
|
||||||
|
|
||||||
const handleClick = (event: MouseEvent) => {
|
|
||||||
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
||||||
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>(anchorSelector) : null;
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
openConfirmModal({
|
|
||||||
title: tBoard("action.edit.confirmLeave.title"),
|
|
||||||
children: tBoard("action.edit.confirmLeave.message"),
|
|
||||||
onConfirm() {
|
|
||||||
router.push(target.href);
|
|
||||||
},
|
|
||||||
confirmProps: {
|
|
||||||
children: tCommon("action.discard"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePopState = (event: Event) => {
|
|
||||||
window.history.pushState(null, document.title, window.location.href);
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
||||||
if (env.NODE_ENV === "development") return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("click", handleClick, true);
|
|
||||||
window.addEventListener("popstate", handlePopState);
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("click", handleClick, true);
|
|
||||||
window.removeEventListener("popstate", handlePopState);
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
};
|
|
||||||
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
|
|
||||||
|
import type { Board } from "../../_types";
|
||||||
|
import { ColorSettingsContent } from "./_appereance";
|
||||||
|
import { BackgroundSettingsContent } from "./_background";
|
||||||
|
import { LayoutPreview } from "./_layout-preview";
|
||||||
|
import type { FormValues } from "./_settings-form";
|
||||||
|
import classes from "./_settings-form.module.css";
|
||||||
|
|
||||||
|
export const AppearancePlayground = ({
|
||||||
|
board,
|
||||||
|
form,
|
||||||
|
apps,
|
||||||
|
selectedLayoutId,
|
||||||
|
}: {
|
||||||
|
board: Board;
|
||||||
|
form: UseFormReturnType<FormValues>;
|
||||||
|
apps: RouterOutputs["app"]["byIds"];
|
||||||
|
selectedLayoutId: string | null;
|
||||||
|
}) => {
|
||||||
|
const layout =
|
||||||
|
form.values.layouts.find((candidate) => candidate.id === selectedLayoutId) ??
|
||||||
|
form.values.layouts.find((candidate) => candidate.role === "base");
|
||||||
|
const sourceLayout =
|
||||||
|
board.layouts.find((candidate) => candidate.id === layout?.id) ??
|
||||||
|
board.layouts.find((candidate) => candidate.role === "base");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.appearance}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<ColorSettingsContent form={form} />
|
||||||
|
<BackgroundSettingsContent form={form} />
|
||||||
|
</Stack>
|
||||||
|
{layout && sourceLayout && (
|
||||||
|
<div className={classes.appearancePreview}>
|
||||||
|
<LayoutPreview
|
||||||
|
board={board}
|
||||||
|
layout={layout}
|
||||||
|
layouts={form.values.layouts}
|
||||||
|
sourceLayout={sourceLayout}
|
||||||
|
apps={apps}
|
||||||
|
settings={form.values}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Anchor,
|
|
||||||
Button,
|
Button,
|
||||||
Collapse,
|
Collapse,
|
||||||
ColorInput,
|
ColorInput,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
InputWrapper,
|
InputWrapper,
|
||||||
isLightColor,
|
isLightColor,
|
||||||
Select,
|
|
||||||
Slider,
|
Slider,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -21,7 +19,7 @@ import { IconX } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { BoardColorInput } from "@homarr/ui";
|
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
|
|
||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
@@ -39,7 +37,6 @@ const progressPercentageLabel = (value: number) => `${value}%`;
|
|||||||
export const ColorSettingsContent = ({ form }: Props) => {
|
export const ColorSettingsContent = ({ form }: Props) => {
|
||||||
const [showPreview, { toggle }] = useDisclosure(false);
|
const [showPreview, { toggle }] = useDisclosure(false);
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tCommon = useI18n("common");
|
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const { branding } = useSettings();
|
const { branding } = useSettings();
|
||||||
|
|
||||||
@@ -50,6 +47,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<BoardColorInput
|
<BoardColorInput
|
||||||
label={tBoard("field.primaryColor.label")}
|
label={tBoard("field.primaryColor.label")}
|
||||||
|
eyeDropperButtonProps={{
|
||||||
|
"aria-label": tBoard("setting.section.appearance.pickColor", {
|
||||||
|
label: tBoard("field.primaryColor.label"),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
|
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
|
||||||
disabled={branding.lockPrimaryColor}
|
disabled={branding.lockPrimaryColor}
|
||||||
{...form.getInputProps("primaryColor")}
|
{...form.getInputProps("primaryColor")}
|
||||||
@@ -57,10 +59,22 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
|
<BoardColorInput
|
||||||
|
label={tBoard("field.secondaryColor.label")}
|
||||||
|
eyeDropperButtonProps={{
|
||||||
|
"aria-label": tBoard("setting.section.appearance.pickColor", {
|
||||||
|
label: tBoard("field.secondaryColor.label"),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
{...form.getInputProps("secondaryColor")}
|
||||||
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
|
<Button type="button" variant="subtle" size="compact-sm" onClick={toggle} aria-expanded={showPreview}>
|
||||||
|
{showPreview
|
||||||
|
? tBoard("setting.section.appearance.hideShades")
|
||||||
|
: tBoard("setting.section.appearance.showShades")}
|
||||||
|
</Button>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={12}>
|
<Grid.Col span={12}>
|
||||||
<Collapse expanded={showPreview}>
|
<Collapse expanded={showPreview}>
|
||||||
@@ -73,6 +87,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
|||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Grid.Col span={{ sm: 12, md: 6 }}>
|
||||||
<InputWrapper label={tBoard("field.opacity.label")}>
|
<InputWrapper label={tBoard("field.opacity.label")}>
|
||||||
<Slider
|
<Slider
|
||||||
|
thumbLabel={tBoard("field.opacity.label")}
|
||||||
my={6}
|
my={6}
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -86,6 +101,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
|||||||
<Group align="end">
|
<Group align="end">
|
||||||
<ColorInput
|
<ColorInput
|
||||||
label={tBoard("field.iconColor.label")}
|
label={tBoard("field.iconColor.label")}
|
||||||
|
eyeDropperButtonProps={{
|
||||||
|
"aria-label": tBoard("setting.section.appearance.pickColor", {
|
||||||
|
label: tBoard("field.iconColor.label"),
|
||||||
|
}),
|
||||||
|
}}
|
||||||
format="hex"
|
format="hex"
|
||||||
swatches={Object.values(theme.colors).map((color) => color[6])}
|
swatches={Object.values(theme.colors).map((color) => color[6])}
|
||||||
flex={1}
|
flex={1}
|
||||||
@@ -102,18 +122,18 @@ export const ColorSettingsContent = ({ form }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ sm: 12, md: 6 }}>
|
<Grid.Col span={12}>
|
||||||
<Select
|
<CornerStylePicker
|
||||||
|
compact
|
||||||
label={tBoard("field.itemRadius.label")}
|
label={tBoard("field.itemRadius.label")}
|
||||||
description={tBoard("field.itemRadius.description")}
|
description={tBoard("field.itemRadius.description")}
|
||||||
data={[
|
value={form.values.itemRadius}
|
||||||
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
|
labels={
|
||||||
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
|
Object.fromEntries(
|
||||||
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
|
cornerStyleValues.map((cornerStyle) => [cornerStyle, tBoard(`field.itemRadius.option.${cornerStyle}`)]),
|
||||||
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
|
) as Record<(typeof cornerStyleValues)[number], string>
|
||||||
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
|
}
|
||||||
]}
|
onChange={(cornerStyle) => form.setFieldValue("itemRadius", cornerStyle)}
|
||||||
{...form.getInputProps("itemRadius")}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { startTransition } from "react";
|
import { startTransition } from "react";
|
||||||
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Autocomplete, Grid, Group, Popover, Stack, Text } from "@mantine/core";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ interface Props {
|
|||||||
|
|
||||||
export const BackgroundSettingsContent = ({ form }: Props) => {
|
export const BackgroundSettingsContent = ({ form }: Props) => {
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
|
const tMedia = useI18n("media");
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
|
||||||
@@ -50,14 +51,15 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
|||||||
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
<Group wrap="nowrap" gap="xs" w="100%" align="start">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
flex={1}
|
flex={1}
|
||||||
|
leftSectionPointerEvents="all"
|
||||||
leftSection={
|
leftSection={
|
||||||
form.values.backgroundImageUrl &&
|
form.values.backgroundImageUrl &&
|
||||||
form.values.backgroundImageUrl.trim().length >= 2 && (
|
form.values.backgroundImageUrl.trim().length >= 2 && (
|
||||||
<Popover width={300} withArrow>
|
<Popover width={300} withArrow>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Center h="100%">
|
<ActionIcon type="button" variant="subtle" aria-label={tMedia("field.preview")}>
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
|
||||||
</Center>
|
</ActionIcon>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
|
||||||
@@ -113,7 +115,15 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ onClick, loading }) => (
|
{({ onClick, loading }) => (
|
||||||
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
|
<ActionIcon
|
||||||
|
type="button"
|
||||||
|
aria-label={tMedia("action.upload.label")}
|
||||||
|
onClick={onClick}
|
||||||
|
loading={loading}
|
||||||
|
mt={24}
|
||||||
|
size={36}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
<IconUpload size={16} stroke={1.5} />
|
<IconUpload size={16} stroke={1.5} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,70 +1,6 @@
|
|||||||
.canvas {
|
.preview {
|
||||||
border: 1px solid var(--mantine-color-default-border);
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-body) 82%, var(--mantine-primary-color-light));
|
|
||||||
padding: var(--mantine-spacing-xs);
|
|
||||||
min-height: 7rem;
|
|
||||||
max-height: min(18rem, 38dvh);
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-default) 78%, transparent);
|
|
||||||
padding: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 3px;
|
|
||||||
grid-auto-rows: clamp(1.35rem, 2.2vw, 1.75rem);
|
|
||||||
min-height: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile {
|
|
||||||
container-type: inline-size;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 24%, var(--mantine-color-default-border));
|
|
||||||
border-radius: calc(var(--mantine-radius-sm) * 0.75);
|
|
||||||
background: color-mix(in srgb, var(--mantine-primary-color-light) 70%, var(--mantine-color-body));
|
|
||||||
padding-inline: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tileContent {
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
max-width: 32rem;
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tileLabel {
|
|
||||||
display: none;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-height: 2.1em;
|
margin-inline: auto;
|
||||||
overflow: hidden;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (min-width: 9rem) {
|
|
||||||
.tileContent {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tileLabel {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.containerTile {
|
|
||||||
border-style: dashed;
|
|
||||||
background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light));
|
|
||||||
}
|
|
||||||
|
|
||||||
.appIcon {
|
|
||||||
width: 0.85rem;
|
|
||||||
height: 0.85rem;
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
|
import { useMemo } from "react";
|
||||||
import { IconLayoutGrid } from "@tabler/icons-react";
|
import { Badge, ColorSwatch, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
||||||
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
|
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
|
||||||
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
|
||||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
|
||||||
|
|
||||||
|
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
|
||||||
|
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
|
import type { FormValues } from "./_settings-form";
|
||||||
import classes from "./_layout-preview.module.css";
|
import classes from "./_layout-preview.module.css";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,107 +20,55 @@ interface Props {
|
|||||||
layouts: BoardPreviewLayout[];
|
layouts: BoardPreviewLayout[];
|
||||||
sourceLayout: BoardPreviewLayout;
|
sourceLayout: BoardPreviewLayout;
|
||||||
apps: RouterOutputs["app"]["byIds"];
|
apps: RouterOutputs["app"]["byIds"];
|
||||||
|
settings: FormValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => {
|
export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps, settings }: Props) => {
|
||||||
|
const t = useI18n("board.setting.section.layout.preview");
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tSection = useI18n("section");
|
const preview = useMemo(() => {
|
||||||
const t = useI18n();
|
const appsById = new Map(apps.map((app) => [app.id, app]));
|
||||||
const elements = projectBoardLayout(board, sourceLayout, layout);
|
return {
|
||||||
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
|
layouts,
|
||||||
const largestRepresentativeWidth = Math.max(
|
sections: board.sections,
|
||||||
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
|
items: board.items.map((item) => {
|
||||||
);
|
let iconUrl: string | undefined;
|
||||||
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
|
if (item.kind === "app" && typeof item.options.appId === "string")
|
||||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
iconUrl = appsById.get(item.options.appId)?.iconUrl;
|
||||||
const rootSections = board.sections
|
return { ...item, iconUrl };
|
||||||
.filter((section) => section.kind === "empty")
|
}),
|
||||||
.toSorted((sectionA, sectionB) => sectionA.xOffset - sectionB.xOffset);
|
};
|
||||||
|
}, [board, layouts, apps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={6} align="center" w="100%">
|
<Stack gap="xs" className={classes.preview} data-testid="board-layout-preview">
|
||||||
<Group gap="xs" justify="center">
|
<Group justify="space-between" gap="xs">
|
||||||
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
|
<Text size="sm" fw={600}>
|
||||||
{representativeWidth}px
|
{t("title")}
|
||||||
</Badge>
|
|
||||||
<Text size="xs">
|
|
||||||
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Badge variant="light" size="sm">
|
||||||
|
{Math.max(320, getRepresentativeLayoutWidth(layout, layouts))} px
|
||||||
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
|
<BoardLayoutThumbnail preview={preview} label={t("canvasLabel")} canvas={{ layout, sourceLayout, settings }} />
|
||||||
{elements.length === 0 ? (
|
<Group justify="space-between" gap="xs">
|
||||||
<Text size="xs" c="dimmed" ta="center" py="xl">
|
<Text size="xs" c="dimmed">
|
||||||
{tBoard("setting.section.layout.preview.empty")}
|
{settings.fixedScaling
|
||||||
</Text>
|
? t("dimensions", { size: normalizeFixedItemSize(settings.fixedItemSize), columns: layout.columnCount })
|
||||||
) : (
|
: t("responsiveDimensions", { columns: layout.columnCount })}
|
||||||
<Stack gap="xs">
|
</Text>
|
||||||
{rootSections.map((section) => {
|
<Group gap={6}>
|
||||||
const sectionElements = elements.filter((element) => element.sectionId === section.id);
|
<Tooltip label={tBoard("field.primaryColor.label")}>
|
||||||
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
|
<ColorSwatch color={settings.primaryColor} size={16} aria-hidden />
|
||||||
if (sectionElements.length === 0 || columnCount === 0) return null;
|
</Tooltip>
|
||||||
|
<Tooltip label={tBoard("field.secondaryColor.label")}>
|
||||||
return (
|
<ColorSwatch color={settings.secondaryColor} size={16} aria-hidden />
|
||||||
<Stack key={section.id} gap={4} className={classes.section}>
|
</Tooltip>
|
||||||
<Box
|
</Group>
|
||||||
className={classes.grid}
|
</Group>
|
||||||
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
|
<Text size="xs" c="dimmed">
|
||||||
>
|
{t("description")}
|
||||||
{sectionElements.map((element) => {
|
</Text>
|
||||||
const item =
|
|
||||||
element.type === "item" ? board.items.find((candidate) => candidate.id === element.id) : null;
|
|
||||||
const appId =
|
|
||||||
item?.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null;
|
|
||||||
const app = appId ? appsById.get(appId) : undefined;
|
|
||||||
const container =
|
|
||||||
element.type === "section"
|
|
||||||
? board.sections.find((candidate) => candidate.id === element.id)
|
|
||||||
: undefined;
|
|
||||||
const containerLabel =
|
|
||||||
container?.kind === "container" && container.options.title
|
|
||||||
? container.options.title
|
|
||||||
: tSection("container.untitled");
|
|
||||||
const label = app?.name ?? (item ? getWidgetName(item.kind, t) : containerLabel);
|
|
||||||
const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={`${element.type}-${element.id}`} label={label} openDelay={350}>
|
|
||||||
<Box
|
|
||||||
role="img"
|
|
||||||
aria-label={label}
|
|
||||||
className={`${classes.tile} ${element.type === "section" ? classes.containerTile : ""}`}
|
|
||||||
style={{
|
|
||||||
gridColumn: `${element.xOffset + 1} / span ${element.width}`,
|
|
||||||
gridRow: `${element.yOffset + 1} / span ${element.height}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group gap={4} wrap="nowrap" className={classes.tileContent}>
|
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm" style={{ flexShrink: 0 }}>
|
|
||||||
{app ? (
|
|
||||||
<MaskedOrNormalImage
|
|
||||||
imageUrl={app.iconUrl}
|
|
||||||
hasColor={false}
|
|
||||||
alt=""
|
|
||||||
className={classes.appIcon}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<WidgetIcon size={13} stroke={1.7} />
|
|
||||||
)}
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text component="span" size="xs" fw={500} className={classes.tileLabel}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,54 @@
|
|||||||
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scalingSettings {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 0.6fr);
|
||||||
|
gap: var(--mantine-spacing-xl);
|
||||||
|
padding: var(--mantine-spacing-lg);
|
||||||
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
background: var(--mantine-color-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scalingControls {
|
||||||
|
align-self: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedItemSizePreview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--mantine-spacing-md);
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixedItemSizePreviewTile {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--mantine-primary-color-contrast);
|
||||||
|
background: var(--mantine-primary-color-filled);
|
||||||
|
border: 1px solid var(--mantine-primary-color-filled-hover);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
.scalingSettings {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 62em) {
|
||||||
|
.layoutPreview {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.gutterPreview {
|
.gutterPreview {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -41,6 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.fixedItemSizePreviewTile,
|
||||||
.gutterPreview {
|
.gutterPreview {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
|
Alert,
|
||||||
Slider,
|
Slider,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
@@ -30,6 +33,7 @@ import { InlineConfirmButton } from "@homarr/ui";
|
|||||||
import { SectionCard } from "~/components/manage/section-card";
|
import { SectionCard } from "~/components/manage/section-card";
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { LayoutPreview } from "./_layout-preview";
|
import { LayoutPreview } from "./_layout-preview";
|
||||||
|
import { ScalingSettings } from "./_scaling";
|
||||||
import type { FormValues } from "./_settings-form";
|
import type { FormValues } from "./_settings-form";
|
||||||
import classes from "./_layout.module.css";
|
import classes from "./_layout.module.css";
|
||||||
|
|
||||||
@@ -38,11 +42,22 @@ interface Props {
|
|||||||
form: UseFormReturnType<FormValues>;
|
form: UseFormReturnType<FormValues>;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
saveSettingsAsync: () => Promise<FormValues | null>;
|
saveSettingsAsync: () => Promise<FormValues | null>;
|
||||||
|
selectedLayoutId: string | null;
|
||||||
|
setSelectedLayoutId: (value: string | null) => void;
|
||||||
|
apps: RouterOutputs["app"]["byIds"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
|
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
|
||||||
|
|
||||||
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
|
export const LayoutSettingsContent = ({
|
||||||
|
board,
|
||||||
|
form,
|
||||||
|
isSaving,
|
||||||
|
saveSettingsAsync,
|
||||||
|
selectedLayoutId,
|
||||||
|
setSelectedLayoutId,
|
||||||
|
apps,
|
||||||
|
}: Props) => {
|
||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const tLayout = useI18n("layout");
|
const tLayout = useI18n("layout");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
@@ -58,18 +73,6 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const appIds = useMemo(
|
|
||||||
() =>
|
|
||||||
Array.from(
|
|
||||||
new Set(
|
|
||||||
board.items.flatMap((item) =>
|
|
||||||
item.kind === "app" && typeof item.options.appId === "string" ? [item.options.appId] : [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[board.items],
|
|
||||||
);
|
|
||||||
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
|
|
||||||
|
|
||||||
const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
|
const openLayoutEditorAsync = async (layout: FormValues["layouts"][number]) => {
|
||||||
setEditingLayoutId(layout.id);
|
setEditingLayoutId(layout.id);
|
||||||
@@ -106,10 +109,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
const displayedLayouts = form.values.layouts
|
const displayedLayouts = form.values.layouts
|
||||||
.map((layout, index) => ({ layout, index }))
|
.map((layout, index) => ({ layout, index }))
|
||||||
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
|
||||||
|
const activeLayoutId =
|
||||||
|
displayedLayouts.find(({ layout }) => layout.id === selectedLayoutId)?.layout.id ?? baseLayout?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
<ScalingSettings form={form} />
|
||||||
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
<Stack gap={2} maw="52rem">
|
<Stack gap={2} maw="52rem">
|
||||||
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
||||||
@@ -133,141 +140,169 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
|||||||
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
let insertionIndex = form.values.layouts.findIndex((layout) => layout.breakpoint > nextBreakpoint);
|
||||||
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
|
||||||
form.insertListItem("layouts", newLayout, insertionIndex);
|
form.insertListItem("layouts", newLayout, insertionIndex);
|
||||||
|
setSelectedLayoutId(newLayout.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tBoard("setting.section.layout.responsive.action.add")}
|
{tBoard("setting.section.layout.responsive.action.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{displayedLayouts.map(({ layout, index }) => {
|
<Select
|
||||||
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
label={tBoard("setting.section.layout.preview.layout")}
|
||||||
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
value={activeLayoutId}
|
||||||
|
onChange={setSelectedLayoutId}
|
||||||
|
allowDeselect={false}
|
||||||
|
error={form.errors.layouts}
|
||||||
|
data={displayedLayouts.map(({ layout, index }) => ({
|
||||||
|
value: layout.id,
|
||||||
|
label: `${layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)} · ${layout.columnCount} ${tBoard("setting.section.layout.preview.columns")}${Object.keys(form.errors).some((key) => key.startsWith(`layouts.${index}.`)) ? " ⚠" : ""}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
{Object.entries(form.errors)
|
||||||
<Fieldset
|
.filter(([key]) => key.startsWith("layouts."))
|
||||||
key={layout.id}
|
.map(([key, error]) => (
|
||||||
legend={
|
<Alert key={key} color="red">
|
||||||
<Group gap="xs">
|
{error}
|
||||||
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
|
</Alert>
|
||||||
<Badge size="sm" variant="default">
|
))}
|
||||||
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
|
{displayedLayouts
|
||||||
</Badge>
|
.filter(({ layout }) => layout.id === activeLayoutId)
|
||||||
</Group>
|
.map(({ layout, index }) => {
|
||||||
}
|
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
|
||||||
bg="transparent"
|
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
|
||||||
>
|
|
||||||
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
|
return (
|
||||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
<Fieldset
|
||||||
<Stack gap="md">
|
key={layout.id}
|
||||||
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
|
legend={
|
||||||
<Input.Wrapper label={tLayout("field.columnCount.label")}>
|
<Group gap="xs">
|
||||||
<Slider
|
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
|
||||||
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
<Badge size="sm" variant="default">
|
||||||
mt="xs"
|
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
|
||||||
min={1}
|
</Badge>
|
||||||
max={24}
|
</Group>
|
||||||
step={1}
|
}
|
||||||
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
|
bg="transparent"
|
||||||
styles={{
|
>
|
||||||
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
|
<Grid gap="xl" align="flex-start">
|
||||||
}}
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
value={layout.columnCount}
|
<Stack gap="md">
|
||||||
onChange={(columnCount) => {
|
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
|
||||||
const left = Math.min(layout.leftGutterColumnCount, Math.max(0, columnCount - 1));
|
<Input.Wrapper
|
||||||
const right = Math.min(layout.rightGutterColumnCount, Math.max(0, columnCount - left - 1));
|
label={tLayout("field.columnCount.label")}
|
||||||
form.setFieldValue(`layouts.${index}.columnCount`, columnCount);
|
description={tLayout("field.columnCount.description")}
|
||||||
form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, left);
|
>
|
||||||
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, right);
|
<Slider
|
||||||
}}
|
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
||||||
|
mt="xs"
|
||||||
|
mb="lg"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
marks={[1, 6, 12, 18, 24].map((value) => ({ value, label: String(value) }))}
|
||||||
|
styles={{
|
||||||
|
markLabel: { color: "light-dark(var(--mantine-color-black), var(--mantine-color-white))" },
|
||||||
|
}}
|
||||||
|
value={layout.columnCount}
|
||||||
|
onChange={(columnCount) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Input.Wrapper>
|
||||||
|
<NumberInput
|
||||||
|
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
||||||
|
label={tLayout("field.breakpoint.label")}
|
||||||
|
description={
|
||||||
|
layout.role === "mobile"
|
||||||
|
? tBoard("setting.section.layout.mobile.breakpointDescription")
|
||||||
|
: tLayout("field.breakpoint.description")
|
||||||
|
}
|
||||||
|
disabled={layout.role === "mobile"}
|
||||||
|
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
||||||
|
min={layout.role === "mobile" ? 0 : 1}
|
||||||
|
max={32767}
|
||||||
|
allowDecimal={false}
|
||||||
/>
|
/>
|
||||||
</Input.Wrapper>
|
{layout.role !== "mobile" && (
|
||||||
<NumberInput
|
<GutterSettings
|
||||||
{...form.getInputProps(`layouts.${index}.breakpoint`)}
|
left={layout.leftGutterColumnCount}
|
||||||
label={tLayout("field.breakpoint.label")}
|
right={layout.rightGutterColumnCount}
|
||||||
description={
|
columnCount={layout.columnCount}
|
||||||
layout.role === "mobile"
|
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
||||||
? tBoard("setting.section.layout.mobile.breakpointDescription")
|
onRightChange={(value) =>
|
||||||
: tLayout("field.breakpoint.description")
|
form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)
|
||||||
}
|
}
|
||||||
disabled={layout.role === "mobile"}
|
/>
|
||||||
styles={{ description: { color: "var(--mantine-color-text)" } }}
|
)}
|
||||||
min={layout.role === "mobile" ? 0 : 1}
|
</Stack>
|
||||||
max={32767}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
|
|
||||||
{sourceLayout && (
|
|
||||||
<LayoutPreview
|
|
||||||
board={board}
|
|
||||||
layout={layout}
|
|
||||||
layouts={form.values.layouts}
|
|
||||||
sourceLayout={sourceLayout}
|
|
||||||
apps={apps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Grid.Col>
|
|
||||||
{layout.role !== "mobile" && (
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
|
||||||
<GutterSettings
|
|
||||||
left={layout.leftGutterColumnCount}
|
|
||||||
right={layout.rightGutterColumnCount}
|
|
||||||
columnCount={layout.columnCount}
|
|
||||||
onLeftChange={(value) => form.setFieldValue(`layouts.${index}.leftGutterColumnCount`, value)}
|
|
||||||
onRightChange={(value) => form.setFieldValue(`layouts.${index}.rightGutterColumnCount`, value)}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
)}
|
<Grid.Col span={{ base: 12, md: 8 }} className={classes.layoutPreview}>
|
||||||
</Grid>
|
{sourceLayout && (
|
||||||
|
<LayoutPreview
|
||||||
|
board={board}
|
||||||
|
layout={layout}
|
||||||
|
layouts={form.values.layouts}
|
||||||
|
sourceLayout={sourceLayout}
|
||||||
|
apps={apps}
|
||||||
|
settings={form.values}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{layout.role !== "base" && persistedLayout && (
|
{layout.role !== "base" && persistedLayout && (
|
||||||
<InlineConfirmButton
|
<InlineConfirmButton
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
|
||||||
loading={resettingLayoutId === layout.id}
|
loading={resettingLayoutId === layout.id}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
onConfirm={() => handleReset(layout)}
|
onConfirm={() => handleReset(layout)}
|
||||||
confirmLabel={tCommon("action.confirm")}
|
confirmLabel={tCommon("action.confirm")}
|
||||||
>
|
>
|
||||||
{tBoard("setting.section.layout.reset.action")}
|
{tBoard("setting.section.layout.reset.action")}
|
||||||
</InlineConfirmButton>
|
</InlineConfirmButton>
|
||||||
)}
|
)}
|
||||||
{layout.role === "custom" && (
|
{layout.role === "custom" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
form.setFieldValue(
|
form.setFieldValue(
|
||||||
"layouts",
|
"layouts",
|
||||||
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
|
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tCommon("action.remove")}
|
{tCommon("action.remove")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
|
||||||
|
loading={editingLayoutId === layout.id}
|
||||||
|
disabled={isSaving || !form.isValid()}
|
||||||
|
onClick={() => void openLayoutEditorAsync(layout)}
|
||||||
|
>
|
||||||
|
{form.isDirty() || !persistedLayout
|
||||||
|
? tBoard("setting.section.layout.edit.saveAndEdit")
|
||||||
|
: tBoard("setting.section.layout.edit.action")}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
</Fieldset>
|
||||||
type="button"
|
);
|
||||||
variant="default"
|
})}
|
||||||
leftSection={<IconEdit size={16} color="var(--mantine-color-red-5)" />}
|
|
||||||
loading={editingLayoutId === layout.id}
|
|
||||||
disabled={isSaving || !form.isValid()}
|
|
||||||
onClick={() => void openLayoutEditorAsync(layout)}
|
|
||||||
>
|
|
||||||
{form.isDirty() || !persistedLayout
|
|
||||||
? tBoard("setting.section.layout.edit.saveAndEdit")
|
|
||||||
: tBoard("setting.section.layout.edit.action")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Fieldset>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { Box, Group, Input, NumberInput, SegmentedControl, Slider, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { BOARD_FIXED_ITEM_SIZE_MAX, BOARD_FIXED_ITEM_SIZE_MIN } from "@homarr/definitions";
|
||||||
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateBoardUiScale,
|
||||||
|
calculateFixedBoardCanvasScale,
|
||||||
|
normalizeFixedItemSize,
|
||||||
|
} from "~/components/board/layout/scaling";
|
||||||
|
import appearanceClasses from "~/components/board/layout/canvas-appearance.module.css";
|
||||||
|
import type { FormValues } from "./_settings-form";
|
||||||
|
import classes from "./_layout.module.css";
|
||||||
|
|
||||||
|
export const ScalingSettings = ({ form }: { form: UseFormReturnType<FormValues> }) => {
|
||||||
|
const t = useI18n("board");
|
||||||
|
const size = normalizeFixedItemSize(form.values.fixedItemSize);
|
||||||
|
const canvasScale = calculateFixedBoardCanvasScale(size);
|
||||||
|
const sampleUiScale = (canvasScale * calculateBoardUiScale(canvasScale)) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={classes.scalingSettings}>
|
||||||
|
<Stack gap="md" className={classes.scalingControls}>
|
||||||
|
<Input.Wrapper label={t("setting.section.layout.scaling.title")}>
|
||||||
|
<SegmentedControl
|
||||||
|
fullWidth
|
||||||
|
mt={6}
|
||||||
|
aria-label={t("setting.section.layout.scaling.title")}
|
||||||
|
value={form.values.fixedScaling ? "fixed" : "responsive"}
|
||||||
|
onChange={(mode) => form.setFieldValue("fixedScaling", mode === "fixed")}
|
||||||
|
data={[
|
||||||
|
{ value: "fixed", label: t("field.fixedScaling.label") },
|
||||||
|
{ value: "responsive", label: t("setting.section.layout.scaling.fit") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Input.Wrapper>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{form.values.fixedScaling
|
||||||
|
? t("field.fixedScaling.description")
|
||||||
|
: t("setting.section.layout.scaling.fitDescription")}
|
||||||
|
</Text>
|
||||||
|
<Group align="end" wrap="nowrap" gap="lg">
|
||||||
|
<NumberInput
|
||||||
|
label={t("field.fixedItemSize.label")}
|
||||||
|
min={BOARD_FIXED_ITEM_SIZE_MIN}
|
||||||
|
max={BOARD_FIXED_ITEM_SIZE_MAX}
|
||||||
|
step={10}
|
||||||
|
allowDecimal={false}
|
||||||
|
disabled={!form.values.fixedScaling}
|
||||||
|
suffix=" px"
|
||||||
|
w={150}
|
||||||
|
{...form.getInputProps("fixedItemSize")}
|
||||||
|
onBlur={() => form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))}
|
||||||
|
/>
|
||||||
|
<Box flex={1} pb={8}>
|
||||||
|
<Slider
|
||||||
|
thumbLabel={t("field.fixedItemSize.label")}
|
||||||
|
min={BOARD_FIXED_ITEM_SIZE_MIN}
|
||||||
|
max={BOARD_FIXED_ITEM_SIZE_MAX}
|
||||||
|
step={10}
|
||||||
|
disabled={!form.values.fixedScaling}
|
||||||
|
value={size}
|
||||||
|
onChange={(value) => form.setFieldValue("fixedItemSize", value)}
|
||||||
|
label={(value) => `${value} px`}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("setting.section.layout.scaling.sizeDescription")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Box
|
||||||
|
component="figure"
|
||||||
|
className={classes.fixedItemSizePreview}
|
||||||
|
aria-label={t("field.fixedItemSize.previewLabel", { size })}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
className={`${classes.fixedItemSizePreviewTile} ${appearanceClasses.appearance}`}
|
||||||
|
style={{
|
||||||
|
width: size / 2,
|
||||||
|
height: size / 2,
|
||||||
|
borderRadius: `var(--mantine-radius-${form.values.itemRadius})`,
|
||||||
|
"--board-canvas-ui-scale": sampleUiScale,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
1 × 1
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box component="figcaption" ta="center">
|
||||||
|
<Text size="sm" fw={600} ff="monospace">
|
||||||
|
{t("field.fixedItemSize.previewValue", { size })}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("setting.section.layout.scaling.halfSize")}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.settings {
|
||||||
|
--mantine-color-dimmed: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs > button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding-top: var(--mantine-spacing-lg);
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearance {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--mantine-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearancePreview {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--app-shell-header-offset, 0px) + var(--mantine-spacing-md));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 62em) {
|
||||||
|
.appearance {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearancePreview {
|
||||||
|
position: static;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button, Stack } from "@mantine/core";
|
import { Alert, Button, Stack, Tabs, VisuallyHidden } from "@mantine/core";
|
||||||
|
import { IconAdjustments, IconCode, IconLayoutGrid, IconPalette, IconUsers } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { env } from "@homarr/common/env";
|
import { env } from "@homarr/common/env";
|
||||||
|
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useSettings } from "@homarr/settings";
|
import { useSettings } from "@homarr/settings";
|
||||||
@@ -20,8 +23,7 @@ import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
|
|||||||
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
|
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
|
||||||
|
|
||||||
import type { Board } from "../../_types";
|
import type { Board } from "../../_types";
|
||||||
import { ColorSettingsContent } from "./_appereance";
|
import { AppearancePlayground } from "./_appearance-playground";
|
||||||
import { BackgroundSettingsContent } from "./_background";
|
|
||||||
import { BehaviorSettingsContent } from "./_behavior";
|
import { BehaviorSettingsContent } from "./_behavior";
|
||||||
import { BoardAccessSettings } from "./_board-access";
|
import { BoardAccessSettings } from "./_board-access";
|
||||||
import { CustomCssSettingsContent } from "./_customCss";
|
import { CustomCssSettingsContent } from "./_customCss";
|
||||||
@@ -29,6 +31,7 @@ import { DangerZoneSettingsContent } from "./_danger";
|
|||||||
import { GeneralSettingsContent } from "./_general";
|
import { GeneralSettingsContent } from "./_general";
|
||||||
import { LayoutSettingsContent } from "./_layout";
|
import { LayoutSettingsContent } from "./_layout";
|
||||||
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
|
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
|
||||||
|
import classes from "./_settings-form.module.css";
|
||||||
|
|
||||||
const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
const boardSettingsFormSchema = boardSavePartialSettingsSchema
|
||||||
.extend({
|
.extend({
|
||||||
@@ -59,6 +62,8 @@ const PARTIAL_FORM_KEYS = [
|
|||||||
"itemRadius",
|
"itemRadius",
|
||||||
"customCss",
|
"customCss",
|
||||||
"disableStatus",
|
"disableStatus",
|
||||||
|
"fixedScaling",
|
||||||
|
"fixedItemSize",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const buildInitialValues = (board: Board): FormValues => ({
|
const buildInitialValues = (board: Board): FormValues => ({
|
||||||
@@ -77,6 +82,8 @@ const buildInitialValues = (board: Board): FormValues => ({
|
|||||||
itemRadius: board.itemRadius,
|
itemRadius: board.itemRadius,
|
||||||
customCss: board.customCss ?? "",
|
customCss: board.customCss ?? "",
|
||||||
disableStatus: board.disableStatus,
|
disableStatus: board.disableStatus,
|
||||||
|
fixedScaling: board.fixedScaling ?? false,
|
||||||
|
fixedItemSize: normalizeFixedItemSize(board.fixedItemSize ?? ""),
|
||||||
layouts: normalizeMobileLayoutGutters(board.layouts),
|
layouts: normalizeMobileLayoutGutters(board.layouts),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +101,21 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
|||||||
const { updateBoard } = useUpdateBoard();
|
const { updateBoard } = useUpdateBoard();
|
||||||
const savePartialSettings = useSavePartialSettingsMutation(board);
|
const savePartialSettings = useSavePartialSettingsMutation(board);
|
||||||
const saveLayouts = useSaveLayoutsMutation(board);
|
const saveLayouts = useSaveLayoutsMutation(board);
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>("layout");
|
||||||
|
const [selectedLayoutId, setSelectedLayoutId] = useState<string | null>(null);
|
||||||
|
const appIds = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
board.items.flatMap((item) => {
|
||||||
|
if (item.kind === "app" && typeof item.options.appId === "string") return [item.options.appId];
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[board.items],
|
||||||
|
);
|
||||||
|
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
|
||||||
|
|
||||||
const form = useZodForm(boardSettingsFormSchema, {
|
const form = useZodForm(boardSettingsFormSchema, {
|
||||||
initialValues: buildInitialValues(board),
|
initialValues: buildInitialValues(board),
|
||||||
@@ -120,7 +142,22 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
|||||||
};
|
};
|
||||||
}, [updateBoard]);
|
}, [updateBoard]);
|
||||||
|
|
||||||
|
const revealValidationErrors = (errors: typeof form.errors) => {
|
||||||
|
const firstField = Object.keys(errors)[0];
|
||||||
|
if (firstField) setActiveTab(getSettingsTabForField(firstField));
|
||||||
|
const layoutField = Object.keys(errors).find((field) => /^layouts\.\d+\./.test(field));
|
||||||
|
if (layoutField) {
|
||||||
|
const layout = form.values.layouts[Number(layoutField.split(".")[1])];
|
||||||
|
if (layout) setSelectedLayoutId(layout.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveSettingsAsync = async (values: FormValues): Promise<FormValues | null> => {
|
const saveSettingsAsync = async (values: FormValues): Promise<FormValues | null> => {
|
||||||
|
const validation = form.validate();
|
||||||
|
if (validation.hasErrors) {
|
||||||
|
revealValidationErrors(validation.errors);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const defaults = initialValuesRef.current;
|
const defaults = initialValuesRef.current;
|
||||||
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
|
||||||
fields.some((field) => values[field] !== defaults[field]);
|
fields.some((field) => values[field] !== defaults[field]);
|
||||||
@@ -165,6 +202,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
|
form.clearErrors();
|
||||||
form.setValues(initialValuesRef.current);
|
form.setValues(initialValuesRef.current);
|
||||||
form.resetDirty();
|
form.resetDirty();
|
||||||
updateBoard((previous) => ({
|
updateBoard((previous) => ({
|
||||||
@@ -177,45 +215,101 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
|||||||
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xl">
|
<Tabs
|
||||||
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
|
value={activeTab}
|
||||||
<Stack gap="xl">
|
onChange={setActiveTab}
|
||||||
<GeneralSettingsContent board={board} form={form} />
|
keepMounted={false}
|
||||||
|
classNames={{ root: classes.settings, list: classes.tabs, panel: classes.panel }}
|
||||||
|
>
|
||||||
|
<VisuallyHidden>
|
||||||
|
<h2>{tSection("navigation")}</h2>
|
||||||
|
</VisuallyHidden>
|
||||||
|
<Tabs.List aria-label={tSection("navigation")}>
|
||||||
|
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={16} />}>
|
||||||
|
{tSection("layout.title")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="appearance" leftSection={<IconPalette size={16} />}>
|
||||||
|
{tSection("appearance.title")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="general" leftSection={<IconAdjustments size={16} />}>
|
||||||
|
{tSection("general.title")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="advanced" leftSection={<IconCode size={16} />}>
|
||||||
|
{tSection("advanced")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
{hasFullAccess && (
|
||||||
|
<Tabs.Tab value="access" leftSection={<IconUsers size={16} />}>
|
||||||
|
{tSection("access.title")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
)}
|
||||||
|
</Tabs.List>
|
||||||
|
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values), revealValidationErrors)}>
|
||||||
|
{Object.keys(form.errors).length > 0 && (
|
||||||
|
<Alert color="red" mt="md">
|
||||||
|
{tSection("validationError")}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Tabs.Panel value="layout">
|
||||||
<LayoutSettingsContent
|
<LayoutSettingsContent
|
||||||
board={board}
|
board={board}
|
||||||
form={form}
|
form={form}
|
||||||
isSaving={isPending}
|
isSaving={isPending}
|
||||||
saveSettingsAsync={() => saveSettingsAsync(form.values)}
|
saveSettingsAsync={() => saveSettingsAsync(form.values)}
|
||||||
|
selectedLayoutId={selectedLayoutId}
|
||||||
|
setSelectedLayoutId={setSelectedLayoutId}
|
||||||
|
apps={apps}
|
||||||
/>
|
/>
|
||||||
<BackgroundSettingsContent form={form} />
|
</Tabs.Panel>
|
||||||
<ColorSettingsContent form={form} />
|
<Tabs.Panel value="appearance">
|
||||||
|
<AppearancePlayground board={board} form={form} apps={apps} selectedLayoutId={selectedLayoutId} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="general">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<GeneralSettingsContent board={board} form={form} />
|
||||||
|
<BehaviorSettingsContent form={form} />
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
<Tabs.Panel value="advanced">
|
||||||
<CustomCssSettingsContent form={form} />
|
<CustomCssSettingsContent form={form} />
|
||||||
<BehaviorSettingsContent form={form} />
|
</Tabs.Panel>
|
||||||
|
|
||||||
{form.isDirty() && (
|
{form.isDirty() && (
|
||||||
<UnsavedChangesBar>
|
<UnsavedChangesBar>
|
||||||
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
|
||||||
{t("action.discard")}
|
{t("action.discard")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
<Button loading={isPending} type="submit">
|
||||||
{t("action.saveChanges")}
|
{t("action.saveChanges")}
|
||||||
</Button>
|
</Button>
|
||||||
</UnsavedChangesBar>
|
</UnsavedChangesBar>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{hasFullAccess && (
|
{hasFullAccess && (
|
||||||
<SectionCard title={tSection("access.title")}>
|
<Tabs.Panel value="access">
|
||||||
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
<Stack gap="lg">
|
||||||
</SectionCard>
|
<SectionCard title={tSection("access.title")}>
|
||||||
|
<BoardAccessSettings board={board} initialPermissions={permissions} />
|
||||||
|
</SectionCard>
|
||||||
|
<DangerZoneSettingsContent hideVisibility={hideVisibility} />
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
)}
|
)}
|
||||||
|
</Tabs>
|
||||||
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
|
|
||||||
</Stack>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSettingsTabForField = (field: string) => {
|
||||||
|
if (field.startsWith("layouts") || field === "fixedScaling" || field === "fixedItemSize") return "layout";
|
||||||
|
if (field === "customCss") return "advanced";
|
||||||
|
if (
|
||||||
|
field.startsWith("background") ||
|
||||||
|
["primaryColor", "secondaryColor", "opacity", "iconColor", "itemRadius"].includes(field)
|
||||||
|
)
|
||||||
|
return "appearance";
|
||||||
|
return "general";
|
||||||
|
};
|
||||||
|
|
||||||
// Previously part of the general settings section; applied on the unified save
|
// Previously part of the general settings section; applied on the unified save
|
||||||
// https://github.com/homarr-labs/homarr/issues/4905
|
// https://github.com/homarr-labs/homarr/issues/4905
|
||||||
const updateFavicon = (url: string) => {
|
const updateFavicon = (url: string) => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { env } from "~/env";
|
|||||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||||
import type { Board } from "./_types";
|
import type { Board } from "./_types";
|
||||||
import { BoardProviders } from "./_providers";
|
import { BoardProviders } from "./_providers";
|
||||||
|
import { BoardEditingProvider } from "./(content)/_editing-provider";
|
||||||
import type { Params } from "./(content)/_creator";
|
import type { Params } from "./(content)/_creator";
|
||||||
import { CustomCss } from "./(content)/_custom-css";
|
import { CustomCss } from "./(content)/_custom-css";
|
||||||
import { BoardReadyProvider } from "./(content)/_ready-context";
|
import { BoardReadyProvider } from "./(content)/_ready-context";
|
||||||
@@ -106,20 +107,25 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
<BoardReadyProvider>
|
<BoardReadyProvider>
|
||||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||||
<ModalProvider>
|
<ModalProvider>
|
||||||
<CustomCss />
|
<BoardEditingProvider
|
||||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
enabled={Boolean(headerBoardEditAction) && hasChangeAccess}
|
||||||
<ClientShell hasNavigation={false}>
|
demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY}
|
||||||
<MainHeader
|
>
|
||||||
logo={<BoardLogo size={appShellLogoHeight} />}
|
<CustomCss />
|
||||||
logoWithTitle={<BoardLogoWithTitle size="md" />}
|
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||||
actions={headerActions}
|
<ClientShell hasNavigation={false}>
|
||||||
boardEditAction={headerBoardEditAction}
|
<MainHeader
|
||||||
boardSettingsAction={headerBoardSettingsAction}
|
logo={<BoardLogo size={appShellLogoHeight} />}
|
||||||
hasNavigation={false}
|
logoWithTitle={<BoardLogoWithTitle size="md" />}
|
||||||
/>
|
actions={headerActions}
|
||||||
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
boardEditAction={headerBoardEditAction}
|
||||||
</ClientShell>
|
boardSettingsAction={headerBoardSettingsAction}
|
||||||
</BoardTourGate>
|
hasNavigation={false}
|
||||||
|
/>
|
||||||
|
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
|
||||||
|
</ClientShell>
|
||||||
|
</BoardTourGate>
|
||||||
|
</BoardEditingProvider>
|
||||||
</ModalProvider>
|
</ModalProvider>
|
||||||
</BoardMantineProvider>
|
</BoardMantineProvider>
|
||||||
</BoardReadyProvider>
|
</BoardReadyProvider>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
CopyButton,
|
CopyButton,
|
||||||
Group,
|
Group,
|
||||||
InputWrapper,
|
InputWrapper,
|
||||||
|
NumberInput,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
IconCheck,
|
IconCheck,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconFileTypeCss,
|
IconFileTypeCss,
|
||||||
|
IconLayoutGrid,
|
||||||
IconPalette,
|
IconPalette,
|
||||||
IconPhoto,
|
IconPhoto,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
@@ -38,10 +40,17 @@ import type { z } from "zod/v4";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
|
import {
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||||
|
backgroundImageAttachments,
|
||||||
|
backgroundImageRepeats,
|
||||||
|
backgroundImageSizes,
|
||||||
|
} from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
|
||||||
|
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAssistantBoardSettingsResult,
|
getAssistantBoardSettingsResult,
|
||||||
@@ -85,6 +94,8 @@ const normalizeFormValues = (
|
|||||||
iconColor: values.iconColor ?? "",
|
iconColor: values.iconColor ?? "",
|
||||||
itemRadius: values.itemRadius,
|
itemRadius: values.itemRadius,
|
||||||
disableStatus: values.disableStatus,
|
disableStatus: values.disableStatus,
|
||||||
|
fixedScaling: values.fixedScaling ?? false,
|
||||||
|
fixedItemSize: normalizeFixedItemSize(values.fixedItemSize ?? ""),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -263,6 +274,9 @@ const BoardSettingsForm = ({
|
|||||||
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
|
<Tabs.Tab value="css" leftSection={<IconFileTypeCss size={15} />}>
|
||||||
{t("tabs.css")}
|
{t("tabs.css")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={15} />}>
|
||||||
|
{t("tabs.layout")}
|
||||||
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
|
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
|
||||||
{t("tabs.appearance")}
|
{t("tabs.appearance")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -329,6 +343,29 @@ const BoardSettingsForm = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="layout" pt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Switch
|
||||||
|
label={boardT("board.field.fixedScaling.label")}
|
||||||
|
description={boardT("board.field.fixedScaling.description")}
|
||||||
|
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label={boardT("board.field.fixedItemSize.label")}
|
||||||
|
description={boardT("board.field.fixedItemSize.description")}
|
||||||
|
min={BOARD_FIXED_ITEM_SIZE_MIN}
|
||||||
|
max={BOARD_FIXED_ITEM_SIZE_MAX}
|
||||||
|
step={10}
|
||||||
|
allowDecimal={false}
|
||||||
|
suffix=" px"
|
||||||
|
{...form.getInputProps("fixedItemSize")}
|
||||||
|
onBlur={() =>
|
||||||
|
form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="appearance" pt="md">
|
<Tabs.Panel value="appearance" pt="md">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const assistantBoardSettingKeys = [
|
|||||||
"iconColor",
|
"iconColor",
|
||||||
"itemRadius",
|
"itemRadius",
|
||||||
"disableStatus",
|
"disableStatus",
|
||||||
|
"fixedScaling",
|
||||||
|
"fixedItemSize",
|
||||||
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
|
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
|
||||||
|
|
||||||
const comparableValue = (value: unknown) => (value === null ? "" : value);
|
const comparableValue = (value: unknown) => (value === null ? "" : value);
|
||||||
@@ -54,12 +56,14 @@ const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
|
|||||||
"backgroundImageRepeat",
|
"backgroundImageRepeat",
|
||||||
"backgroundImageSize",
|
"backgroundImageSize",
|
||||||
]);
|
]);
|
||||||
|
const layoutKeys = new Set<keyof AssistantBoardSettingsChanges>(["fixedScaling", "fixedItemSize"]);
|
||||||
|
|
||||||
export const getAssistantBoardSettingsDefaultTab = (
|
export const getAssistantBoardSettingsDefaultTab = (
|
||||||
changes: AssistantBoardSettingsChanges,
|
changes: AssistantBoardSettingsChanges,
|
||||||
): "general" | "appearance" | "background" | "css" => {
|
): "general" | "layout" | "appearance" | "background" | "css" => {
|
||||||
if (changes.customCss !== undefined) return "css";
|
if (changes.customCss !== undefined) return "css";
|
||||||
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
|
const keys = Object.keys(changes) as (keyof AssistantBoardSettingsChanges)[];
|
||||||
|
if (keys.some((key) => layoutKeys.has(key))) return "layout";
|
||||||
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
|
if (keys.some((key) => appearanceKeys.has(key))) return "appearance";
|
||||||
if (keys.some((key) => backgroundKeys.has(key))) return "background";
|
if (keys.some((key) => backgroundKeys.has(key))) return "background";
|
||||||
return "general";
|
return "general";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||||
backgroundImageAttachments,
|
backgroundImageAttachments,
|
||||||
backgroundImageRepeats,
|
backgroundImageRepeats,
|
||||||
backgroundImageSizes,
|
backgroundImageSizes,
|
||||||
@@ -48,6 +50,17 @@ export const assistantBoardSettingsChangesSchema = z
|
|||||||
iconColor: boardColorSchema.nullable().optional(),
|
iconColor: boardColorSchema.nullable().optional(),
|
||||||
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
|
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).optional(),
|
||||||
disableStatus: z.boolean().optional(),
|
disableStatus: z.boolean().optional(),
|
||||||
|
fixedScaling: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Keep board tiles at a configured size instead of fitting the complete layout to the viewport."),
|
||||||
|
fixedItemSize: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(BOARD_FIXED_ITEM_SIZE_MIN)
|
||||||
|
.max(BOARD_FIXED_ITEM_SIZE_MAX)
|
||||||
|
.optional()
|
||||||
|
.describe("Visible pixel width and height of a 1 by 1 tile when fixedScaling is enabled."),
|
||||||
})
|
})
|
||||||
.describe("Only the board fields the user asked to change.");
|
.describe("Only the board fields the user asked to change.");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu } from "@mantine/core";
|
||||||
|
import { IconBox, IconLayoutGridAdd, IconPlug, IconResize } from "@tabler/icons-react";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { useBoardAddActions } from "./use-board-add-actions";
|
||||||
|
import { useOptionalBoardEditing } from "./board-editing-context";
|
||||||
|
|
||||||
|
export const BoardAddMenuItems = () => {
|
||||||
|
const editing = useOptionalBoardEditing();
|
||||||
|
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||||
|
const tBoard = useI18n("board");
|
||||||
|
const tIntegration = useI18n("integration");
|
||||||
|
const tContainer = useI18n("section.container");
|
||||||
|
const run = async (action: () => void) => {
|
||||||
|
if (await editing?.enter()) action();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu.Item leftSection={<IconResize size={20} />} onClick={() => void run(addWidget)}>
|
||||||
|
{tBoard("emptyState.addWidget")}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item leftSection={<IconBox size={20} />} onClick={() => void run(addApp)}>
|
||||||
|
{tBoard("emptyState.addApp")}
|
||||||
|
</Menu.Item>
|
||||||
|
{canConnectService && (
|
||||||
|
<Menu.Item leftSection={<IconPlug size={20} />} onClick={() => void run(connectService)}>
|
||||||
|
{tIntegration("action.create")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={() => void run(addContainer)}>
|
||||||
|
{tContainer("action.create")}
|
||||||
|
</Menu.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PropsWithChildren, MouseEvent } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Box, Menu } from "@mantine/core";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useOptionalBoardEditing } from "./board-editing-context";
|
||||||
|
|
||||||
|
const BoardAddMenuItems = dynamic(() => import("./board-add-menu-items").then((module) => module.BoardAddMenuItems));
|
||||||
|
const occupiedSpaceSelector =
|
||||||
|
'[data-grid-item-id], [data-editor-grid-entry], [data-item-id], button, a, input, textarea, select, [contenteditable], [role="button"], [role="menu"], [role="dialog"]';
|
||||||
|
|
||||||
|
export const BoardContextMenu = ({ children }: PropsWithChildren) => {
|
||||||
|
const editing = useOptionalBoardEditing();
|
||||||
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!position) return;
|
||||||
|
const close = () => setPosition(null);
|
||||||
|
window.addEventListener("scroll", close, true);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", close, true);
|
||||||
|
window.removeEventListener("resize", close);
|
||||||
|
};
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
|
const openMenu = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!editing?.hasChangeAccess || editing.isPending || event.defaultPrevented || event.shiftKey) return;
|
||||||
|
if (!(event.target instanceof Element) || event.target.closest(occupiedSpaceSelector)) return;
|
||||||
|
if (window.getSelection()?.toString()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setPosition({ x: event.clientX, y: event.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box h="100%" onContextMenu={openMenu}>
|
||||||
|
{children}
|
||||||
|
<Menu
|
||||||
|
opened={position !== null && Boolean(editing?.hasChangeAccess)}
|
||||||
|
onChange={(opened) => {
|
||||||
|
if (!opened) setPosition(null);
|
||||||
|
}}
|
||||||
|
position="bottom-start"
|
||||||
|
offset={2}
|
||||||
|
withinPortal
|
||||||
|
withArrow={false}
|
||||||
|
shadow="md"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: position?.x ?? 0,
|
||||||
|
top: position?.y ?? 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>{position && <BoardAddMenuItems />}</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
import type { BoardEditingControls } from "~/app/[locale]/boards/(content)/_editing-provider";
|
||||||
|
|
||||||
|
export const BoardEditingContext = createContext<BoardEditingControls | null>(null);
|
||||||
|
export const useOptionalBoardEditing = () => useContext(BoardEditingContext);
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { IconLayoutGrid } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { BoardPreviewData, BoardPreviewLayout } from "@homarr/boards/layout-preview";
|
||||||
|
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
|
||||||
|
import type { GridAlgorithmItem } from "@homarr/common";
|
||||||
|
import { getRootSectionLane } from "@homarr/definitions";
|
||||||
|
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||||
|
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||||
|
|
||||||
|
import { getBoardCanvasGeometry } from "./layout/canvas-geometry";
|
||||||
|
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./layout/constants";
|
||||||
|
import { calculateBoardUiScale, getBoardCanvasMetrics, getBoardScaling } from "./layout/scaling";
|
||||||
|
import { useCanvasViewportSize } from "./layout/use-canvas-viewport-size";
|
||||||
|
import appearanceClasses from "./layout/canvas-appearance.module.css";
|
||||||
|
import scrollbarClasses from "./layout/canvas-scrollbar.module.css";
|
||||||
|
import classes from "./board-layout-thumbnail.module.css";
|
||||||
|
|
||||||
|
export interface BoardLayoutThumbnailCanvasProps {
|
||||||
|
preview: BoardPreviewData;
|
||||||
|
label: string;
|
||||||
|
className?: string;
|
||||||
|
canvas: {
|
||||||
|
layout: BoardPreviewLayout;
|
||||||
|
sourceLayout: BoardPreviewLayout;
|
||||||
|
settings: {
|
||||||
|
fixedScaling: boolean;
|
||||||
|
fixedItemSize: number;
|
||||||
|
opacity: number;
|
||||||
|
itemRadius: string;
|
||||||
|
iconColor: string | null;
|
||||||
|
backgroundImageUrl: string | null;
|
||||||
|
backgroundImageSize: string;
|
||||||
|
backgroundImageRepeat: string;
|
||||||
|
backgroundImageAttachment: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Boards thumbnail with exact canvas geometry for the settings draft. */
|
||||||
|
export const BoardLayoutThumbnailCanvas = ({ preview, label, canvas, className }: BoardLayoutThumbnailCanvasProps) => {
|
||||||
|
const { layout, sourceLayout, settings } = canvas;
|
||||||
|
const { ref: viewportRef, width, height } = useCanvasViewportSize();
|
||||||
|
const screenWidth = Math.max(320, getRepresentativeLayoutWidth(layout, preview.layouts));
|
||||||
|
const miniatureScale = width / screenWidth;
|
||||||
|
const geometry = getBoardCanvasGeometry(layout);
|
||||||
|
const metrics = getBoardCanvasMetrics(getBoardScaling(settings), screenWidth - 32, geometry.width);
|
||||||
|
const visualUiScale = metrics.scale * calculateBoardUiScale(metrics.scale);
|
||||||
|
const elements = useMemo(() => projectBoardLayout(preview, sourceLayout, layout), [preview, sourceLayout, layout]);
|
||||||
|
const roots = preview.sections.filter((section) => section.kind === "empty");
|
||||||
|
const rootIds = new Set(roots.map((section) => section.id));
|
||||||
|
const rootElements = elements.filter((element) => rootIds.has(element.sectionId));
|
||||||
|
const rowCount = Math.max(2, ...rootElements.map((element) => element.yOffset + element.height));
|
||||||
|
const pitch = LOGICAL_GRID_PITCH * metrics.scale;
|
||||||
|
const canvasOffset = Math.max(0, (screenWidth - 32 - metrics.width) / 2);
|
||||||
|
const contentWidth = Math.max(screenWidth, metrics.width + 32);
|
||||||
|
const canvasTop = 16;
|
||||||
|
const contentHeight = rowCount * pitch + canvasTop + 16;
|
||||||
|
const itemsById = new Map(preview.items.map((item) => [item.id, item]));
|
||||||
|
const stationaryBackground = settings.fixedScaling || settings.backgroundImageAttachment === "fixed";
|
||||||
|
const backgroundStyle = {
|
||||||
|
backgroundImage: settings.backgroundImageUrl ? `url(${JSON.stringify(settings.backgroundImageUrl)})` : undefined,
|
||||||
|
backgroundSize: settings.backgroundImageSize,
|
||||||
|
backgroundRepeat: settings.backgroundImageRepeat,
|
||||||
|
backgroundPosition: "center center",
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderIcon = (id: string) => {
|
||||||
|
const item = itemsById.get(id);
|
||||||
|
const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : IconLayoutGrid;
|
||||||
|
if (item?.iconUrl) {
|
||||||
|
return (
|
||||||
|
<MaskedOrNormalImage
|
||||||
|
imageUrl={item.iconUrl}
|
||||||
|
hasColor={Boolean(settings.iconColor)}
|
||||||
|
color={settings.iconColor || undefined}
|
||||||
|
alt=""
|
||||||
|
className={classes.scaledItemIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <WidgetIcon className={classes.scaledItemIcon} stroke={1.7} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the familiar nested-container representation without mounting live widgets.
|
||||||
|
const renderContents = (element: GridAlgorithmItem) => {
|
||||||
|
if (element.type === "item") return renderIcon(element.id);
|
||||||
|
const nestedItems = elements.filter((candidate) => candidate.sectionId === element.id);
|
||||||
|
const columns = Math.max(element.width, ...nestedItems.map((item) => item.xOffset + item.width));
|
||||||
|
const rows = Math.max(element.height, ...nestedItems.map((item) => item.yOffset + item.height));
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={classes.containerContents}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nestedItems.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className={classes.tile}
|
||||||
|
style={{
|
||||||
|
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
|
||||||
|
gridRow: `${item.yOffset + 1} / span ${item.height}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderIcon(item.id)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={[classes.canvas, classes.exactCanvas, className].filter(Boolean).join(" ")}>
|
||||||
|
{stationaryBackground && (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
className={classes.scaledSurface}
|
||||||
|
style={{
|
||||||
|
...backgroundStyle,
|
||||||
|
width: screenWidth,
|
||||||
|
height: height / (miniatureScale || 1),
|
||||||
|
transform: `scale(${miniatureScale})`,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
ref={viewportRef}
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={0}
|
||||||
|
className={`${classes.thumbnailViewport} ${scrollbarClasses.scrollbar}`}
|
||||||
|
data-testid="layout-preview-viewport"
|
||||||
|
data-preview-scale={miniatureScale}
|
||||||
|
data-item-size={metrics.itemSize}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: contentWidth * miniatureScale,
|
||||||
|
height: contentHeight * miniatureScale,
|
||||||
|
minHeight: "100%",
|
||||||
|
overflow: "clip",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
className={`${classes.scaledSurface} ${appearanceClasses.appearance}`}
|
||||||
|
style={{
|
||||||
|
width: contentWidth,
|
||||||
|
height: Math.max(contentHeight, (screenWidth * 7) / 16),
|
||||||
|
transform: `scale(${miniatureScale})`,
|
||||||
|
"--board-canvas-ui-scale": visualUiScale,
|
||||||
|
"--preview-opacity": settings.opacity / 100,
|
||||||
|
"--preview-radius": `var(--mantine-radius-${settings.itemRadius})`,
|
||||||
|
color: settings.iconColor || undefined,
|
||||||
|
...(!stationaryBackground && backgroundStyle),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{geometry.lanes.map((lane) => {
|
||||||
|
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane.lane);
|
||||||
|
const laneElements = rootElements.filter((element) => element.sectionId === root?.id);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={lane.lane}
|
||||||
|
data-preview-lane={lane.lane}
|
||||||
|
className={classes.scaledLane}
|
||||||
|
style={{
|
||||||
|
left: 16 + canvasOffset + lane.offset * metrics.scale,
|
||||||
|
top: canvasTop,
|
||||||
|
width: lane.width * metrics.scale,
|
||||||
|
height: rowCount * pitch,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{laneElements.map((element) => {
|
||||||
|
let inset = BOARD_GRID_ITEM_INSET;
|
||||||
|
if (element.type === "section" && !settings.fixedScaling) inset = 5 * visualUiScale;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={`${element.type}-${element.id}`}
|
||||||
|
className={`${classes.tile} ${classes.scaledTile}`}
|
||||||
|
data-preview-tile
|
||||||
|
data-type={element.type}
|
||||||
|
style={{
|
||||||
|
left: element.xOffset * pitch + inset,
|
||||||
|
top: element.yOffset * pitch + inset,
|
||||||
|
width: Math.max(0, element.width * pitch - 2 * inset),
|
||||||
|
height: Math.max(0, element.height * pitch - 2 * inset),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderContents(element)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,11 +5,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: var(--mantine-color-dimmed);
|
color: var(--mantine-color-dimmed);
|
||||||
background:
|
background:
|
||||||
linear-gradient(
|
linear-gradient(to top, color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), transparent 55%),
|
||||||
to top,
|
|
||||||
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
|
|
||||||
transparent 55%
|
|
||||||
),
|
|
||||||
var(--mantine-color-body);
|
var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,3 +80,60 @@
|
|||||||
gap: rem(1px);
|
gap: rem(1px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exactCanvas {
|
||||||
|
position: relative;
|
||||||
|
max-height: min(18rem, 38dvh);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnailViewport {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnailViewport:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaledSurface {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaledLane,
|
||||||
|
.scaledTile {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaledSurface .tile {
|
||||||
|
border-radius: var(--preview-radius);
|
||||||
|
background: rgb(
|
||||||
|
from light-dark(var(--mantine-color-white), var(--mantine-color-dark-6)) r g b / var(--preview-opacity, 1)
|
||||||
|
);
|
||||||
|
border-color: rgb(
|
||||||
|
from light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) r g b / var(--preview-opacity, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaledTile[data-type="section"] {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scaledItemIcon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: calc(32px * var(--board-canvas-ui-scale, 1));
|
||||||
|
height: calc(32px * var(--board-canvas-ui-scale, 1));
|
||||||
|
max-width: 55%;
|
||||||
|
max-height: 55%;
|
||||||
|
object-fit: contain;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { projectBoardLayout } from "@homarr/boards/layout-preview";
|
|||||||
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||||
|
|
||||||
|
import { BoardLayoutThumbnailCanvas } from "./board-layout-thumbnail-canvas";
|
||||||
|
import type { BoardLayoutThumbnailCanvasProps } from "./board-layout-thumbnail-canvas";
|
||||||
import classes from "./board-layout-thumbnail.module.css";
|
import classes from "./board-layout-thumbnail.module.css";
|
||||||
|
|
||||||
interface BoardLayoutThumbnailProps {
|
interface BoardLayoutThumbnailProps {
|
||||||
@@ -13,6 +15,7 @@ interface BoardLayoutThumbnailProps {
|
|||||||
label: string;
|
label: string;
|
||||||
previewRowLimit?: number;
|
previewRowLimit?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
canvas?: BoardLayoutThumbnailCanvasProps["canvas"];
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactBoardLayoutThumbnailRows = 12;
|
const compactBoardLayoutThumbnailRows = 12;
|
||||||
@@ -23,7 +26,11 @@ export const BoardLayoutThumbnail = ({
|
|||||||
label,
|
label,
|
||||||
previewRowLimit = compactBoardLayoutThumbnailRows,
|
previewRowLimit = compactBoardLayoutThumbnailRows,
|
||||||
className,
|
className,
|
||||||
|
canvas,
|
||||||
}: BoardLayoutThumbnailProps) => {
|
}: BoardLayoutThumbnailProps) => {
|
||||||
|
if (preview && canvas) {
|
||||||
|
return <BoardLayoutThumbnailCanvas preview={preview} label={label} canvas={canvas} className={className} />;
|
||||||
|
}
|
||||||
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
|
||||||
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
|
||||||
if (!preview || !layout) {
|
if (!preview || !layout) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
|
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
|
||||||
|
|
||||||
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
import type { Board, ContainerSection, EmptySection, Item, Section } from "~/app/[locale]/boards/_types";
|
||||||
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
import { ContainerSectionMockBuilder } from "./container-section-mock";
|
||||||
@@ -27,6 +28,8 @@ export class BoardMockBuilder {
|
|||||||
name: "board",
|
name: "board",
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
|
fixedScaling: false,
|
||||||
|
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
disableStatus: false,
|
disableStatus: false,
|
||||||
customCss: "",
|
customCss: "",
|
||||||
creatorId: createId(),
|
creatorId: createId(),
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
transform: translateY(calc(-100% - 8px));
|
transform: translateY(calc(-100% - 8px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep first-row labels inside the scrollport without adding space above the board. */
|
||||||
|
:global([data-canvas-scaling="fixed"] [data-grid-y="0"] > .board-grid-content-mount) > .wrapper {
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@mixin dark {
|
@mixin dark {
|
||||||
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
|
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
|
||||||
|
|||||||
@@ -5,13 +5,33 @@ The board uses fixed logical units:
|
|||||||
- one grid track has a `212 × 212` logical-pixel footprint;
|
- one grid track has a `212 × 212` logical-pixel footprint;
|
||||||
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
- cards are inset by `10` visual pixels on every side, matching the legacy GridStack gutter;
|
||||||
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
- multi-cell items occupy complete track footprints (`2 × 1` is `424 × 212` before the card inset);
|
||||||
- the canvas is zoomed once with one uniform scale derived from its available width.
|
- the canvas is zoomed once with one uniform scale.
|
||||||
|
|
||||||
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
Grid footprints keep the same logical dimensions at every viewport. Widget cards
|
||||||
use a scale-compensated inset so their painted size and content measurements
|
use a scale-compensated inset so their painted size and content measurements
|
||||||
match the legacy responsive grid. The visual canvas always fits the available
|
match the legacy responsive grid. Responsive scaling derives the zoom from the
|
||||||
width, every root lane keeps at least one viewport of height, and edit-mode
|
available width. Fixed scaling derives it from the configured painted `1 x 1`
|
||||||
previews extend it downward as needed.
|
card size and keeps the canvas centered while it fits; otherwise the board's
|
||||||
|
named canvas region scrolls on both axes. Every root lane keeps at least one
|
||||||
|
viewport of height, and edit-mode previews extend it downward as needed.
|
||||||
|
|
||||||
|
## Sizing policy and previews
|
||||||
|
|
||||||
|
`scaling.ts` owns the explicit `BoardScaling` union and pure canvas metrics.
|
||||||
|
`getBoardScaling` is the adapter from the persisted boolean/size settings. Both
|
||||||
|
the live renderer and settings thumbnail consume the same metrics; neither
|
||||||
|
should add viewport fitting outside this module. Fixed sizing uses CSS pixels
|
||||||
|
and never compensates for browser zoom or device-pixel ratio.
|
||||||
|
|
||||||
|
`canvas-geometry.ts` owns lane offsets, widths, and gaps for both renderers.
|
||||||
|
The settings thumbnail applies a separate miniature transform after calculating
|
||||||
|
board geometry at a representative layout width. That display transform must never feed
|
||||||
|
back into the board sizing policy.
|
||||||
|
|
||||||
|
To retire responsive sizing, migrate the persisted default/settings, remove
|
||||||
|
the responsive policy branch and its UI option, then remove the responsive
|
||||||
|
viewport styles. Placement geometry and the fixed renderer do not need a
|
||||||
|
second conversion. Nested Container fitting is a separate layout concern.
|
||||||
|
|
||||||
The read-only renderer should:
|
The read-only renderer should:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
.appearance {
|
||||||
|
/*
|
||||||
|
* Keep dashboard UI at its normal readable density while the fixed canvas
|
||||||
|
* is fitted below 100%. Above 100%, the canvas still behaves like browser
|
||||||
|
* zoom and enlarges everything together.
|
||||||
|
*/
|
||||||
|
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||||
|
/* Keep a subtle boundary inset without widening the visual gap too much. */
|
||||||
|
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
|
||||||
|
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
|
||||||
|
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
|
||||||
|
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
|
||||||
|
* above, just never ported over to radius. */
|
||||||
|
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { boardLanes, getBoardLaneColumnCount } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { getLogicalGridSize } from "./geometry";
|
||||||
|
|
||||||
|
export const BOARD_LANE_GAP = 24;
|
||||||
|
|
||||||
|
/** Shared horizontal geometry for the live board and its settings playground. */
|
||||||
|
export const getBoardCanvasGeometry = (layout: Parameters<typeof getBoardLaneColumnCount>[0]) => {
|
||||||
|
let width = 0;
|
||||||
|
const lanes = boardLanes.flatMap((lane) => {
|
||||||
|
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||||
|
if (columnCount === 0) return [];
|
||||||
|
|
||||||
|
if (width > 0) width += BOARD_LANE_GAP;
|
||||||
|
const offset = width;
|
||||||
|
const laneWidth = getLogicalGridSize(columnCount);
|
||||||
|
width += laneWidth;
|
||||||
|
return [{ lane, columnCount, width: laneWidth, offset }];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { lanes, width, gridTemplateColumns: lanes.map((lane) => `${lane.width}px`).join(" ") };
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
.scrollbar::-webkit-scrollbar {
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar::-webkit-scrollbar-track,
|
||||||
|
.scrollbar::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar:hover::-webkit-scrollbar-thumb,
|
||||||
|
.scrollbar:focus-visible::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--mantine-color-dimmed) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (-moz-appearance: none) {
|
||||||
|
.scrollbar {
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar:hover,
|
||||||
|
.scrollbar:focus-visible {
|
||||||
|
scrollbar-color: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,7 @@
|
|||||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||||
export const LOGICAL_GRID_GAP = 12;
|
export const LOGICAL_GRID_GAP = 12;
|
||||||
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
|
||||||
|
/** Physical inset between a grid footprint and each edge of its painted tile. */
|
||||||
|
export const BOARD_GRID_ITEM_INSET = 10;
|
||||||
|
|
||||||
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
|
||||||
@@ -10,6 +10,36 @@
|
|||||||
overflow: clip;
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewport[data-canvas-scaling="fixed"] {
|
||||||
|
--board-canvas-shell-offset: calc(var(--app-shell-header-offset, 0px) + var(--app-shell-footer-offset, 0px));
|
||||||
|
--board-canvas-shell-padding: calc(2 * var(--app-shell-padding, 1rem));
|
||||||
|
--board-canvas-fixed-viewport-height: calc(
|
||||||
|
100dvh - var(--board-canvas-shell-offset) - var(--board-canvas-shell-padding)
|
||||||
|
);
|
||||||
|
|
||||||
|
height: var(--board-canvas-fixed-viewport-height);
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport[data-canvas-scaling="fixed"]:focus-visible {
|
||||||
|
outline: 2px solid var(--mantine-primary-color-filled);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport[data-canvas-scaling="fixed"][data-canvas-centered="true"] .sizer {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport[data-canvas-scaling="fixed"] .canvas {
|
||||||
|
/* Fixed size describes every painted tile, including container sections. */
|
||||||
|
--board-grid-item-inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport[data-canvas-scaling="fixed"][data-board-hydrated="false"] {
|
||||||
|
overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
.sizer {
|
.sizer {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -17,42 +47,4 @@
|
|||||||
.canvas {
|
.canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 auto auto 0;
|
inset: 0 auto auto 0;
|
||||||
|
|
||||||
/*
|
|
||||||
* Keep dashboard UI at its normal readable density while the fixed canvas
|
|
||||||
* is fitted below 100%. Above 100%, the canvas still behaves like browser
|
|
||||||
* zoom and enlarges everything together.
|
|
||||||
*/
|
|
||||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
|
||||||
/* Keep a subtle boundary inset without widening the visual gap too much. */
|
|
||||||
--board-grid-item-inset: calc(var(--mantine-spacing-xs) / 2);
|
|
||||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
|
|
||||||
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
|
|
||||||
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
|
|
||||||
* above, just never ported over to radius. */
|
|
||||||
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
|
|
||||||
}
|
}
|
||||||
@@ -1,49 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { CSSProperties, PropsWithChildren } from "react";
|
||||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
|
|
||||||
|
import { BOARD_GRID_ITEM_INSET } from "./constants";
|
||||||
|
import type { BoardScaling } from "./scaling";
|
||||||
|
import { calculateBoardUiScale, getBoardCanvasMetrics } from "./scaling";
|
||||||
|
import { useCanvasViewportSize } from "./use-canvas-viewport-size";
|
||||||
import classes from "./scaled-board-canvas.module.css";
|
import classes from "./scaled-board-canvas.module.css";
|
||||||
|
import appearanceClasses from "./canvas-appearance.module.css";
|
||||||
|
import scrollbarClasses from "./canvas-scrollbar.module.css";
|
||||||
|
|
||||||
const BoardCanvasScaleContext = createContext(1);
|
const BoardCanvasScaleContext = createContext(1);
|
||||||
|
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
|
||||||
|
|
||||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||||
|
export const useBoardCanvasViewportHeight = () => useContext(BoardCanvasViewportHeightContext);
|
||||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
|
||||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
|
||||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
|
||||||
|
|
||||||
return availableWidth / logicalWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
|
||||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
|
||||||
|
|
||||||
return canvasScale < 1 ? 1 / canvasScale : 1;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ScaledBoardCanvasProps {
|
interface ScaledBoardCanvasProps {
|
||||||
logicalWidth: number;
|
logicalWidth: number;
|
||||||
initialLogicalHeight: number;
|
initialLogicalHeight: number;
|
||||||
initialAvailableWidth: number;
|
initialAvailableWidth: number;
|
||||||
|
scaling: BoardScaling;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scales the complete board as one surface while keeping widget layout in
|
* Scales the complete board as one surface while keeping widget layout in
|
||||||
* fixed logical pixels. The complete canvas always fits its viewport so board
|
* fixed logical pixels. Responsive scaling fits the canvas to its viewport;
|
||||||
* content can never widen the document or require horizontal scrolling.
|
* fixed scaling contains overflow in a keyboard-scrollable canvas region.
|
||||||
*/
|
*/
|
||||||
export const ScaledBoardCanvas = ({
|
export const ScaledBoardCanvas = ({
|
||||||
logicalWidth,
|
logicalWidth,
|
||||||
initialLogicalHeight,
|
initialLogicalHeight,
|
||||||
initialAvailableWidth,
|
initialAvailableWidth,
|
||||||
|
scaling,
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useCanvasViewportSize();
|
||||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||||
const [isHydrated, setIsHydrated] = useState(false);
|
const [isHydrated, setIsHydrated] = useState(false);
|
||||||
|
|
||||||
@@ -53,40 +50,58 @@ export const ScaledBoardCanvas = ({
|
|||||||
|
|
||||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||||
const scale = useMemo(
|
const isFixedScaling = scaling.mode === "fixed";
|
||||||
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
|
const {
|
||||||
[logicalWidth, resolvedAvailableWidth],
|
scale,
|
||||||
);
|
width: visualWidth,
|
||||||
|
centered,
|
||||||
|
overflows,
|
||||||
|
} = getBoardCanvasMetrics(scaling, resolvedAvailableWidth, logicalWidth);
|
||||||
const uiScale = calculateBoardUiScale(scale);
|
const uiScale = calculateBoardUiScale(scale);
|
||||||
const inverseScale = scale > 0 ? 1 / scale : 1;
|
const inverseScale = scale > 0 ? 1 / scale : 1;
|
||||||
const visualWidth = logicalWidth * scale;
|
|
||||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="section"
|
component="section"
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
className={classes.viewport}
|
className={`${classes.viewport} ${isFixedScaling ? scrollbarClasses.scrollbar : ""}`}
|
||||||
data-testid="board-canvas"
|
data-testid="board-canvas"
|
||||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||||
|
data-canvas-scaling={scaling.mode}
|
||||||
data-canvas-scale={scale}
|
data-canvas-scale={scale}
|
||||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
data-canvas-overflow={overflows ? "true" : "false"}
|
||||||
|
data-canvas-centered={centered ? "true" : undefined}
|
||||||
data-canvas-initial-height={initialLogicalHeight}
|
data-canvas-initial-height={initialLogicalHeight}
|
||||||
data-canvas-initial-width={initialAvailableWidth}
|
data-canvas-initial-width={initialAvailableWidth}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
tabIndex={isFixedScaling ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
|
<Box
|
||||||
|
className={classes.sizer}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--board-canvas-scale": scale,
|
||||||
|
width: visualWidth,
|
||||||
|
height: resolvedLogicalHeight * scale,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={classes.canvas}
|
className={`${classes.canvas} ${appearanceClasses.appearance}`}
|
||||||
style={{
|
style={{
|
||||||
|
"--board-grid-card-inset": `${BOARD_GRID_ITEM_INSET}px`,
|
||||||
"--board-canvas-inverse-scale": inverseScale,
|
"--board-canvas-inverse-scale": inverseScale,
|
||||||
"--board-canvas-ui-scale": uiScale,
|
"--board-canvas-ui-scale": uiScale,
|
||||||
width: logicalWidth,
|
width: logicalWidth,
|
||||||
zoom: scale,
|
zoom: scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
|
<BoardCanvasScaleContext.Provider value={scale}>
|
||||||
|
<BoardCanvasViewportHeightContext.Provider value={isFixedScaling ? viewportHeight : null}>
|
||||||
|
{children}
|
||||||
|
</BoardCanvasViewportHeightContext.Provider>
|
||||||
|
</BoardCanvasScaleContext.Provider>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||||
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
|
||||||
|
|
||||||
|
export type BoardScaling = { mode: "fixed"; itemSize: number } | { mode: "responsive" };
|
||||||
|
|
||||||
|
export const normalizeFixedItemSize = (value: number | string) => {
|
||||||
|
if (value === "" || !Number.isFinite(Number(value))) return BOARD_FIXED_ITEM_SIZE_DEFAULT;
|
||||||
|
|
||||||
|
return Math.min(BOARD_FIXED_ITEM_SIZE_MAX, Math.max(BOARD_FIXED_ITEM_SIZE_MIN, Math.round(Number(value))));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The only adapter from persisted settings to the canvas sizing policy. */
|
||||||
|
export const getBoardScaling = (settings: { fixedScaling: boolean; fixedItemSize: number }): BoardScaling => {
|
||||||
|
if (settings.fixedScaling) return { mode: "fixed", itemSize: normalizeFixedItemSize(settings.fixedItemSize) };
|
||||||
|
|
||||||
|
return { mode: "responsive" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||||
|
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||||
|
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||||
|
|
||||||
|
return availableWidth / logicalWidth;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateFixedBoardCanvasScale = (itemSize: number) =>
|
||||||
|
(normalizeFixedItemSize(itemSize) + 2 * BOARD_GRID_ITEM_INSET) / LOGICAL_GRID_PITCH;
|
||||||
|
|
||||||
|
/** Fixed sizing never reads viewport width or devicePixelRatio: browser zoom stays native. */
|
||||||
|
export const getBoardCanvasMetrics = (scaling: BoardScaling, availableWidth: number, logicalWidth: number) => {
|
||||||
|
let scale: number;
|
||||||
|
if (scaling.mode === "fixed") scale = calculateFixedBoardCanvasScale(scaling.itemSize);
|
||||||
|
else scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||||
|
|
||||||
|
const width = logicalWidth * scale;
|
||||||
|
return {
|
||||||
|
scale,
|
||||||
|
width,
|
||||||
|
itemSize: Math.max(0, LOGICAL_GRID_PITCH * scale - 2 * BOARD_GRID_ITEM_INSET),
|
||||||
|
centered: scaling.mode === "fixed" && width <= availableWidth,
|
||||||
|
overflows: availableWidth > 0 && width - availableWidth > 0.5,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||||
|
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||||
|
if (canvasScale < 1) return 1 / canvasScale;
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
};
|
||||||
@@ -1,8 +1,65 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
|
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { getBoardCanvasGeometry } from "../canvas-geometry";
|
||||||
|
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "../constants";
|
||||||
|
import {
|
||||||
|
calculateBoardCanvasScale,
|
||||||
|
calculateBoardUiScale,
|
||||||
|
calculateFixedBoardCanvasScale,
|
||||||
|
getBoardCanvasMetrics,
|
||||||
|
getBoardScaling,
|
||||||
|
normalizeFixedItemSize,
|
||||||
|
} from "../scaling";
|
||||||
|
|
||||||
describe("board canvas scale policy", () => {
|
describe("board canvas scale policy", () => {
|
||||||
|
test.each([40, 80, 100, 200])("renders an exact %s CSS-pixel fixed tile", (itemSize) => {
|
||||||
|
const scale = calculateFixedBoardCanvasScale(itemSize);
|
||||||
|
expect(LOGICAL_GRID_PITCH * scale - 2 * BOARD_GRID_ITEM_INSET).toBeCloseTo(itemSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([Number.NaN, Number.POSITIVE_INFINITY, ""])(
|
||||||
|
"uses the default fixed size for unavailable input %s",
|
||||||
|
(input) => {
|
||||||
|
expect(normalizeFixedItemSize(input)).toBe(BOARD_FIXED_ITEM_SIZE_DEFAULT);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("clamps and rounds the configured fixed size", () => {
|
||||||
|
expect(normalizeFixedItemSize(20)).toBe(40);
|
||||||
|
expect(normalizeFixedItemSize(400)).toBe(200);
|
||||||
|
expect(normalizeFixedItemSize(100.7)).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([0.75, 1, 1.25, 1.5, 2])("preserves fixed CSS dimensions at browser zoom %s", (zoom) => {
|
||||||
|
const scaling = getBoardScaling({ fixedScaling: true, fixedItemSize: 200 });
|
||||||
|
const metrics = getBoardCanvasMetrics(scaling, 1440 / zoom, 5 * LOGICAL_GRID_PITCH);
|
||||||
|
expect(metrics.itemSize).toBeCloseTo(200);
|
||||||
|
expect(metrics.width).toBeCloseTo(1100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("centers fixed boards that fit and scrolls without changing tile size when narrow", () => {
|
||||||
|
const scaling = getBoardScaling({ fixedScaling: true, fixedItemSize: 200 });
|
||||||
|
expect(getBoardCanvasMetrics(scaling, 2560, 1060)).toMatchObject({ centered: true, overflows: false });
|
||||||
|
expect(getBoardCanvasMetrics(scaling, 600, 1060)).toMatchObject({ centered: false, overflows: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the sizing mode adapter reversible", () => {
|
||||||
|
expect(getBoardScaling({ fixedScaling: false, fixedItemSize: 200 })).toEqual({ mode: "responsive" });
|
||||||
|
expect(getBoardCanvasMetrics({ mode: "responsive" }, 600, 1060).width).toBeCloseTo(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("aligns left, main, and right lanes using the same canvas geometry", () => {
|
||||||
|
const geometry = getBoardCanvasGeometry({ columnCount: 5, leftGutterColumnCount: 1, rightGutterColumnCount: 1 });
|
||||||
|
expect(geometry.lanes.map(({ lane, offset, width }) => ({ lane, offset, width }))).toEqual([
|
||||||
|
{ lane: "left", offset: 0, width: 212 },
|
||||||
|
{ lane: "main", offset: 236, width: 636 },
|
||||||
|
{ lane: "right", offset: 896, width: 212 },
|
||||||
|
]);
|
||||||
|
expect(geometry.width).toBe(1108);
|
||||||
|
});
|
||||||
|
|
||||||
test("fits the logical canvas exactly", () => {
|
test("fits the logical canvas exactly", () => {
|
||||||
const scale = calculateBoardCanvasScale(840, 1000);
|
const scale = calculateBoardCanvasScale(840, 1000);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
/** Canvas fitting uses the scrollport, not the border box including native scrollbars. */
|
||||||
|
export const useCanvasViewportSize = () => {
|
||||||
|
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||||
|
const ref = useCallback((node: HTMLElement | null) => {
|
||||||
|
if (!node) return;
|
||||||
|
const observer = new ResizeObserver(() => setSize({ width: node.clientWidth, height: node.clientHeight }));
|
||||||
|
observer.observe(node);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ref, ...size };
|
||||||
|
};
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.staticItem[data-type="item"] > .contentMount {
|
.staticItem[data-type="item"] > .contentMount {
|
||||||
inset: calc(10px * var(--board-canvas-inverse-scale, 1));
|
inset: calc(var(--board-grid-card-inset, 10px) * var(--board-canvas-inverse-scale, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.staticItem:has([data-board-container-label]) {
|
.staticItem:has([data-board-container-label]) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
|
|||||||
|
|
||||||
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||||
import {
|
import {
|
||||||
|
BOARD_GRID_ITEM_INSET,
|
||||||
getCollapsedDisplayLayout,
|
getCollapsedDisplayLayout,
|
||||||
getEditableCanvasAttributes,
|
getEditableCanvasAttributes,
|
||||||
getGridRowCountForVisualHeight,
|
getGridRowCountForVisualHeight,
|
||||||
@@ -18,7 +19,8 @@ import {
|
|||||||
getReadonlyCanvasAttributes,
|
getReadonlyCanvasAttributes,
|
||||||
normalizeGridPlacement,
|
normalizeGridPlacement,
|
||||||
} from "~/components/board/layout";
|
} from "~/components/board/layout";
|
||||||
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
import { useBoardCanvasScale, useBoardCanvasViewportHeight } from "~/components/board/layout/scaled-board-canvas";
|
||||||
|
import { calculateBoardUiScale } from "~/components/board/layout/scaling";
|
||||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||||
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||||
@@ -50,6 +52,7 @@ export const SectionGrid = ({
|
|||||||
}: SectionGridProps) => {
|
}: SectionGridProps) => {
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const canvasScale = useBoardCanvasScale();
|
const canvasScale = useBoardCanvasScale();
|
||||||
|
const canvasViewportHeight = useBoardCanvasViewportHeight();
|
||||||
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
const editorRuntimeStatus = useGridEditorRuntimeStatus();
|
||||||
const editorRegistry = useGridEditorRegistry();
|
const editorRegistry = useGridEditorRegistry();
|
||||||
const editorHostRef = useRef<HTMLDivElement>(null);
|
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -130,7 +133,11 @@ export const SectionGrid = ({
|
|||||||
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
|
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
|
||||||
[innerSections, placementById],
|
[innerSections, placementById],
|
||||||
);
|
);
|
||||||
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
|
const minimumViewportRowCount = useMinimumViewportRowCount(
|
||||||
|
section.kind === "empty",
|
||||||
|
canvasScale,
|
||||||
|
canvasViewportHeight,
|
||||||
|
);
|
||||||
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
|
||||||
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
|
||||||
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
|
||||||
@@ -150,9 +157,9 @@ export const SectionGrid = ({
|
|||||||
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
|
||||||
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
// the same column/row counts used to allocate the *outer*, uninset cell - so without
|
||||||
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
// subtracting that gap back out here, a container's inner grid renders larger than its own
|
||||||
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
|
// card and visually spills past its right/bottom edges.
|
||||||
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
|
||||||
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
|
const outerCardInset = section.kind === "container" ? (2 * BOARD_GRID_ITEM_INSET) / effectiveCanvasScale : 0;
|
||||||
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
// A collapsible container's toggle bar (see the `containerToggle` Button in
|
||||||
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
|
||||||
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
|
||||||
@@ -342,11 +349,11 @@ const INTERACTIVE_GRID_SELECTOR =
|
|||||||
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
|
||||||
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
|
||||||
|
|
||||||
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number, canvasViewportHeight: number | null) => {
|
||||||
const [visualHeight, setVisualHeight] = useState(0);
|
const [visualHeight, setVisualHeight] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled || canvasViewportHeight !== null) return;
|
||||||
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
|
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
|
||||||
update();
|
update();
|
||||||
window.addEventListener("resize", update, { passive: true });
|
window.addEventListener("resize", update, { passive: true });
|
||||||
@@ -355,9 +362,10 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
|
|||||||
window.removeEventListener("resize", update);
|
window.removeEventListener("resize", update);
|
||||||
window.visualViewport?.removeEventListener("resize", update);
|
window.visualViewport?.removeEventListener("resize", update);
|
||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [canvasViewportHeight, enabled]);
|
||||||
|
|
||||||
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
|
const resolvedVisualHeight = canvasViewportHeight ?? visualHeight;
|
||||||
|
return enabled ? getGridRowCountForVisualHeight(resolvedVisualHeight, canvasScale) : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerMinimumSizeCache = new WeakMap<
|
const containerMinimumSizeCache = new WeakMap<
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconRobot,
|
IconRobot,
|
||||||
IconTool,
|
IconTool,
|
||||||
|
IconPencil,
|
||||||
|
IconDeviceFloppy,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
@@ -25,6 +27,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { Link } from "@homarr/ui";
|
import { Link } from "@homarr/ui";
|
||||||
|
|
||||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||||
|
import { useOptionalBoardEditing } from "./board/board-editing-context";
|
||||||
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
|
||||||
import type { BoardSwitcherControls } from "./board/board-switcher";
|
import type { BoardSwitcherControls } from "./board/board-switcher";
|
||||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||||
@@ -50,6 +53,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
|||||||
const tBoard = useI18n("board");
|
const tBoard = useI18n("board");
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const board = useOptionalBoard();
|
const board = useOptionalBoard();
|
||||||
|
const editing = useOptionalBoardEditing();
|
||||||
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
|
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
|
||||||
|
|
||||||
const { logoutUrl } = useAuthContext();
|
const { logoutUrl } = useAuthContext();
|
||||||
@@ -81,6 +85,18 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
|
|||||||
{tBoard("action.settings")}
|
{tBoard("action.settings")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
{editing?.hasChangeAccess && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={editing.isEditMode ? <IconDeviceFloppy size="1rem" /> : <IconPencil size="1rem" />}
|
||||||
|
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkeys.toggleBoardEdit, t("modifier"))}</Kbd>}
|
||||||
|
disabled={editing.isPending || editing.isEnteringEditMode}
|
||||||
|
onClick={() => void editing.toggle()}
|
||||||
|
onFocus={editing.prewarmEditor}
|
||||||
|
onPointerEnter={editing.prewarmEditor}
|
||||||
|
>
|
||||||
|
{editing.label}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconReplace size="1rem" />}
|
leftSection={<IconReplace size="1rem" />}
|
||||||
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
getWidgetIntegrationIssueMessage,
|
getWidgetIntegrationIssueMessage,
|
||||||
getPermissionsWithChildren,
|
getPermissionsWithChildren,
|
||||||
getPermissionsWithParents,
|
getPermissionsWithParents,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||||
normalizeBoardLayoutRoles,
|
normalizeBoardLayoutRoles,
|
||||||
rootSectionOffsets,
|
rootSectionOffsets,
|
||||||
widgetDefaultSizes,
|
widgetDefaultSizes,
|
||||||
@@ -1142,8 +1144,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
description:
|
description: `Read the editable visual and behavior settings for one board, including its current custom CSS and board scaling settings. fixedScaling keeps each 1x1 grid item at fixedItemSize pixels instead of fitting the board to the viewport; fixedItemSize is ${BOARD_FIXED_ITEM_SIZE_MIN}-${BOARD_FIXED_ITEM_SIZE_MAX}. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes`,
|
||||||
"Read the editable visual and behavior settings for one board, including its current custom CSS. Requires modify permission. REQUIRED: id (board ID). Call this before proposing board settings or custom CSS changes",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
@@ -1170,6 +1171,8 @@ export const boardRouter = createTRPCRouter({
|
|||||||
customCss: true,
|
customCss: true,
|
||||||
iconColor: true,
|
iconColor: true,
|
||||||
itemRadius: true,
|
itemRadius: true,
|
||||||
|
fixedScaling: true,
|
||||||
|
fixedItemSize: true,
|
||||||
disableStatus: true,
|
disableStatus: true,
|
||||||
},
|
},
|
||||||
where: boardWhere,
|
where: boardWhere,
|
||||||
@@ -1526,8 +1529,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
|
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
description:
|
description: `Update visual and behavior settings for a board. Set fixedScaling to true to keep each 1x1 grid item at fixedItemSize pixels instead of fitting the board to the viewport; fixedItemSize must be ${BOARD_FIXED_ITEM_SIZE_MIN}-${BOARD_FIXED_ITEM_SIZE_MAX}. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, fixedScaling, fixedItemSize, and disableStatus`,
|
||||||
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
|
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
|
||||||
@@ -1557,6 +1559,10 @@ export const boardRouter = createTRPCRouter({
|
|||||||
iconColor: input.iconColor,
|
iconColor: input.iconColor,
|
||||||
itemRadius: input.itemRadius,
|
itemRadius: input.itemRadius,
|
||||||
|
|
||||||
|
// scaling settings
|
||||||
|
fixedScaling: input.fixedScaling,
|
||||||
|
fixedItemSize: input.fixedItemSize,
|
||||||
|
|
||||||
// custom css
|
// custom css
|
||||||
customCss: input.customCss,
|
customCss: input.customCss,
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
serverSettings,
|
serverSettings,
|
||||||
} from "@homarr/db/schema";
|
} from "@homarr/db/schema";
|
||||||
import {
|
import {
|
||||||
|
BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
emptySuperJSON,
|
emptySuperJSON,
|
||||||
everyoneGroup,
|
everyoneGroup,
|
||||||
extractContainerImageName,
|
extractContainerImageName,
|
||||||
@@ -388,6 +389,8 @@ export const onboardRouter = createTRPCRouter({
|
|||||||
customCss: null,
|
customCss: null,
|
||||||
iconColor: null,
|
iconColor: null,
|
||||||
itemRadius: input.board.itemRadius,
|
itemRadius: input.board.itemRadius,
|
||||||
|
fixedScaling: false,
|
||||||
|
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
disableStatus: false,
|
disableStatus: false,
|
||||||
sections: [],
|
sections: [],
|
||||||
layouts: pendingLayouts,
|
layouts: pendingLayouts,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `board` ADD `fixed_scaling` boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `board` ADD `fixed_item_size` smallint DEFAULT 200 NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -351,6 +351,13 @@
|
|||||||
"when": 1788763848931,
|
"when": 1788763848931,
|
||||||
"tag": "0049_add_byte_unit_system",
|
"tag": "0049_add_byte_unit_system",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 50,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1788845117219,
|
||||||
|
"tag": "0050_add_fixed_board_scaling",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "board" ADD COLUMN "fixed_scaling" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "board" ADD COLUMN "fixed_item_size" smallint DEFAULT 200 NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -113,6 +113,13 @@
|
|||||||
"when": 1788763848932,
|
"when": 1788763848932,
|
||||||
"tag": "0015_add_byte_unit_system",
|
"tag": "0015_add_byte_unit_system",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788845119083,
|
||||||
|
"tag": "0016_add_fixed_board_scaling",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `board` ADD `fixed_scaling` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `board` ADD `fixed_item_size` integer DEFAULT 200 NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -337,6 +337,13 @@
|
|||||||
"when": 1788763848930,
|
"when": 1788763848930,
|
||||||
"tag": "0047_add_byte_unit_system",
|
"tag": "0047_add_byte_unit_system",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788845055278,
|
||||||
|
"tag": "0048_add_fixed_board_scaling",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
backgroundImageAttachments,
|
backgroundImageAttachments,
|
||||||
backgroundImageRepeats,
|
backgroundImageRepeats,
|
||||||
backgroundImageSizes,
|
backgroundImageSizes,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
emptySuperJSON,
|
emptySuperJSON,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import type {
|
import type {
|
||||||
@@ -301,6 +302,8 @@ export const boards = mysqlTable("board", {
|
|||||||
customCss: text(),
|
customCss: text(),
|
||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
|
fixedScaling: boolean().default(false).notNull(),
|
||||||
|
fixedItemSize: smallint().default(BOARD_FIXED_ITEM_SIZE_DEFAULT).notNull(),
|
||||||
disableStatus: boolean().default(false).notNull(),
|
disableStatus: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
backgroundImageAttachments,
|
backgroundImageAttachments,
|
||||||
backgroundImageRepeats,
|
backgroundImageRepeats,
|
||||||
backgroundImageSizes,
|
backgroundImageSizes,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
emptySuperJSON,
|
emptySuperJSON,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import type {
|
import type {
|
||||||
@@ -299,6 +300,8 @@ export const boards = pgTable("board", {
|
|||||||
customCss: text(),
|
customCss: text(),
|
||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
|
fixedScaling: boolean().default(false).notNull(),
|
||||||
|
fixedItemSize: smallint().default(BOARD_FIXED_ITEM_SIZE_DEFAULT).notNull(),
|
||||||
disableStatus: boolean().default(false).notNull(),
|
disableStatus: boolean().default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
backgroundImageAttachments,
|
backgroundImageAttachments,
|
||||||
backgroundImageRepeats,
|
backgroundImageRepeats,
|
||||||
backgroundImageSizes,
|
backgroundImageSizes,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||||
emptySuperJSON,
|
emptySuperJSON,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
import type {
|
import type {
|
||||||
@@ -284,6 +285,8 @@ export const boards = sqliteTable("board", {
|
|||||||
customCss: text(),
|
customCss: text(),
|
||||||
iconColor: text(),
|
iconColor: text(),
|
||||||
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
itemRadius: text().$type<MantineSize>().default("lg").notNull(),
|
||||||
|
fixedScaling: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
|
fixedItemSize: int().default(BOARD_FIXED_ITEM_SIZE_DEFAULT).notNull(),
|
||||||
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const backgroundImageSizes = createDefinition(["cover", "contain"], {
|
|||||||
});
|
});
|
||||||
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
|
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
|
||||||
|
|
||||||
|
export const BOARD_FIXED_ITEM_SIZE_DEFAULT = 200;
|
||||||
|
export const BOARD_FIXED_ITEM_SIZE_MIN = 40;
|
||||||
|
export const BOARD_FIXED_ITEM_SIZE_MAX = 200;
|
||||||
|
|
||||||
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
||||||
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
||||||
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
||||||
|
|||||||
@@ -5430,6 +5430,16 @@
|
|||||||
"label": "Disable app status",
|
"label": "Disable app status",
|
||||||
"description": "Disables the status check for all apps on this board"
|
"description": "Disables the status check for all apps on this board"
|
||||||
},
|
},
|
||||||
|
"fixedScaling": {
|
||||||
|
"label": "Fixed scaling",
|
||||||
|
"description": "Keep board tiles at the selected size. The dashboard is centred when it fits and scrolls when it does not."
|
||||||
|
},
|
||||||
|
"fixedItemSize": {
|
||||||
|
"label": "1 × 1 tile size",
|
||||||
|
"description": "Set the exact visible width and height used for a 1 × 1 tile when fixed scaling is enabled.",
|
||||||
|
"previewLabel": "1 by 1 tile preview at {size} by {size} pixels",
|
||||||
|
"previewValue": "{size} × {size} px"
|
||||||
|
},
|
||||||
"columnCount": {
|
"columnCount": {
|
||||||
"label": "Column count"
|
"label": "Column count"
|
||||||
},
|
},
|
||||||
@@ -5455,12 +5465,22 @@
|
|||||||
"setting": {
|
"setting": {
|
||||||
"title": "Settings for {boardName} board",
|
"title": "Settings for {boardName} board",
|
||||||
"section": {
|
"section": {
|
||||||
|
"navigation": "Board settings",
|
||||||
|
"advanced": "Advanced",
|
||||||
|
"validationError": "Check the highlighted settings before saving. Layouts with errors are marked in the layout selector.",
|
||||||
"general": {
|
"general": {
|
||||||
"title": "General",
|
"title": "General",
|
||||||
"unrecognizedLink": "The provided link is not recognized and won't preview, it might still work."
|
"unrecognizedLink": "The provided link is not recognized and won't preview, it might still work."
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"title": "Layout",
|
"title": "Layout",
|
||||||
|
"scaling": {
|
||||||
|
"title": "Tile sizing",
|
||||||
|
"fit": "Fit to screen",
|
||||||
|
"fitDescription": "Resize the whole board to fill the available width. Tile size changes with the window.",
|
||||||
|
"sizeDescription": "The size applies in fixed scaling and is kept when you switch modes.",
|
||||||
|
"halfSize": "Tile sample · 50% size"
|
||||||
|
},
|
||||||
"responsive": {
|
"responsive": {
|
||||||
"title": "Responsive layouts",
|
"title": "Responsive layouts",
|
||||||
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
|
"description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts at any additional breakpoint.",
|
||||||
@@ -5480,6 +5500,12 @@
|
|||||||
"breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint."
|
"breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint."
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
"title": "Board preview",
|
||||||
|
"description": "A miniature of this layout at the indicated screen width. Changes appear here before you save.",
|
||||||
|
"layout": "Layout to configure",
|
||||||
|
"canvasLabel": "Scrollable board layout preview",
|
||||||
|
"dimensions": "1 × 1: {size} × {size} px · {columns} columns",
|
||||||
|
"responsiveDimensions": "Fit to screen · {columns} columns",
|
||||||
"columns": "columns",
|
"columns": "columns",
|
||||||
"empty": "This layout is empty",
|
"empty": "This layout is empty",
|
||||||
"dynamicSection": "Dynamic section",
|
"dynamicSection": "Dynamic section",
|
||||||
@@ -5507,7 +5533,10 @@
|
|||||||
"title": "Background"
|
"title": "Background"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance"
|
"title": "Appearance",
|
||||||
|
"showShades": "Show color shades",
|
||||||
|
"hideShades": "Hide color shades",
|
||||||
|
"pickColor": "Pick {label} from the screen"
|
||||||
},
|
},
|
||||||
"customCss": {
|
"customCss": {
|
||||||
"title": "Custom css"
|
"title": "Custom css"
|
||||||
@@ -5607,7 +5636,8 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"field": {
|
"field": {
|
||||||
"columnCount": {
|
"columnCount": {
|
||||||
"label": "Column count"
|
"label": "Column count",
|
||||||
|
"description": "Maximum total columns in this layout, including fixed sidebars."
|
||||||
},
|
},
|
||||||
"breakpoint": {
|
"breakpoint": {
|
||||||
"label": "Breakpoint",
|
"label": "Breakpoint",
|
||||||
@@ -8032,6 +8062,7 @@
|
|||||||
"resetAll": "Reset all changes",
|
"resetAll": "Reset all changes",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"css": "Custom CSS",
|
"css": "Custom CSS",
|
||||||
|
"layout": "Layout",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"background": "Background",
|
"background": "Background",
|
||||||
"general": "General"
|
"general": "General"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
backgroundImageSizes,
|
backgroundImageSizes,
|
||||||
boardPermissions,
|
boardPermissions,
|
||||||
layoutRoles,
|
layoutRoles,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MAX,
|
||||||
|
BOARD_FIXED_ITEM_SIZE_MIN,
|
||||||
widgetKinds,
|
widgetKinds,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
|
||||||
@@ -68,6 +70,8 @@ export const boardSavePartialSettingsSchema = z
|
|||||||
customCss: z.string().max(16384),
|
customCss: z.string().max(16384),
|
||||||
iconColor: hexColorNullableSchema,
|
iconColor: hexColorNullableSchema,
|
||||||
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
|
itemRadius: z.union([z.literal("xs"), z.literal("sm"), z.literal("md"), z.literal("lg"), z.literal("xl")]),
|
||||||
|
fixedScaling: z.boolean(),
|
||||||
|
fixedItemSize: z.number().int().min(BOARD_FIXED_ITEM_SIZE_MIN).max(BOARD_FIXED_ITEM_SIZE_MAX),
|
||||||
disableStatus: z.boolean(),
|
disableStatus: z.boolean(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|||||||
Reference in new issue
Block a user