Compare commits

..
Author SHA1 Message Date
ajnart dbc24e9091 fix: use a compact 40 to 200 pixel tile range 2026-09-09 15:33:14 +02:00
ajnart 9256a432f9 fix: isolate board editing context from global menus 2026-09-09 12:04:18 +02:00
ajnart bed69c7a92 fix: prevent horizontal board auto-scroll 2026-09-08 18:21:24 +02:00
ajnart 6f62f3ca43 feat: add board editing shortcuts to user and context menus 2026-09-08 18:07:22 +02:00
ajnart f23d197a35 fix: remove extra fixed canvas top margin 2026-09-08 17:23:10 +02:00
ajnart 5887735bd5 fix: reuse branding corner picker in board settings 2026-09-08 17:02:25 +02:00
ajnart 6db134cf15 feat: refine board settings and compact previews 2026-09-08 16:17:01 +02:00
ajnart ecc1009e23 feat: add fixed board scaling 2026-09-08 12:37:51 +02:00
Thomas 9d857377c1 fix: refine release v2 interactions (#6805) 2026-09-08 11:36:19 +02:00
Thomas 923905c529 fix: keep bookmark density consistent (#6804)
* fix: keep bookmark density consistent

* fix: prioritize bookmark titles in compact rows

* fix: preserve compact bookmark text fallback
2026-09-08 10:39:41 +02:00
70 changed files with 10276 additions and 834 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

@@ -65,7 +65,7 @@ We have called this the **"ultimate Homarr update"**. Not because work stops, bu
We threw out the old drag-and-drop system and rebuilt it on [`dnd-kit`](https://dndkit.com/). Moves and eight-direction resizes are transactional: Homarr previews the result, checks collisions and only saves a valid layout. Bad moves roll back instead of wrecking the board. In short: less fighting with your own dashboard.
![A Calendar tile repositioned between widgets on the rebuilt Homarr 2.0 board grid](./img/drag-and-drop-still.webp)
![A Calendar tile moving across the rebuilt Homarr 2.0 board grid](./img/drag-and-drop.gif)
### Goodbye Groups and sections, bonjour Containers
+5
View File
@@ -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
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.
## Docker targets
+38 -12
View File
@@ -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
the current viewport.
Tiles use fixed `200 × 200` logical-pixel cells with a `24px` gap. Homarr scales the complete layout to the available
width instead of changing each widget's proportions. **Reset from Base** regenerates a Mobile or custom layout from the
current Base layout.
By default, Homarr fits the complete fixed-proportion tile grid to the available width. Enable **Fixed scaling** in the
board's Layout settings to render each top-level `1 × 1` tile at the configured CSS-pixel size instead. A fixed dashboard is
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
@@ -41,8 +46,8 @@ scrolls and store their contents separately for each layout.
## Edit a board
Enter edit mode from the board header to add, move, resize, configure, or remove content. Save the board to persist the
layout.
Enter edit mode from the board header or account menu to add, move, resize, configure, or remove content. The account
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.
- 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.
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`
click remains available as a shortcut.
## 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:** responsive layouts, breakpoints, columns, and rails.
- **Background:** image, attachment, size, and repeat behavior.
- **Appearance:** colors, opacity, icon color, and item radius.
- **Custom CSS:** styles applied only to the current board.
- **Behaviour:** app status and widget context-menu behavior.
- **Layout:** tile sizing, responsive layouts, breakpoints, maximum columns, and rails.
- **Appearance:** colors, opacity, icon color, item radius, and background image settings beside a live layout preview.
- **General:** page title, browser title, logo, favicon, and app status.
- **Advanced:** custom CSS applied only to this board.
- **Access control:** permissions, visibility, renaming, and deletion (requires full access).
### 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.
+8
View File
@@ -2151,6 +2151,14 @@
}
]
},
"fixedScaling": {
"type": "boolean"
},
"fixedItemSize": {
"type": "integer",
"minimum": 40,
"maximum": 200
},
"disableStatus": {
"type": "boolean"
}
@@ -1,7 +1,6 @@
.columns {
display: grid;
align-items: start;
gap: 24px;
}
.lane {
@@ -27,3 +26,10 @@
outline-offset: -0.0625rem;
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 { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
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 {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalGridSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
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 { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
@@ -60,99 +61,91 @@ export const ClientBoard = () => {
const leftColumnCount = getBoardLaneColumnCount(currentLayout, "left");
const mainColumnCount = getBoardLaneColumnCount(currentLayout, "main");
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalGridSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const { width: logicalWidth, gridTemplateColumns } = getBoardCanvasGeometry(currentLayout);
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
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 = (
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<BoardContextMenu>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
scaling={getBoardScaling(board)}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<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}
>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</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>
</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
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</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>
</BoardSelectionGridProvider>
);
@@ -36,7 +36,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
@@ -3,18 +3,14 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
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 { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { BoardAddMenuItems } from "~/components/board/board-add-menu-items";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -22,16 +18,9 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
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 tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -48,34 +37,6 @@ const AddMenu = () => {
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [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 (
<Menu position="bottom-end">
<Menu.Target>
@@ -87,21 +48,7 @@ const AddMenu = () => {
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{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>
<BoardAddMenuItems />
</Menu.Dropdown>
</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";
import { useCallback, useEffect, useRef, useState } from "react";
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 { 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 { useOptionalBoardEditing } from "~/components/board/board-editing-context";
import { HeaderButton } from "~/components/layout/header/button";
import { TourTarget } from "~/components/layout/header/tour-target";
import type * as EditActionsModule from "./_edit-actions";
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 BoardEditActions = dynamic(() => import("./_edit-actions"), { ssr: false });
export const BoardContentEditAction = () => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
@@ -49,7 +19,7 @@ export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
<EditModeMenu />
</>
);
};
@@ -70,127 +40,26 @@ export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boo
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
const [isEnteringEditMode, setIsEnteringEditMode] = useState(false);
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const tCommon = useI18n("common");
const EditModeMenu = () => {
const editing = useOptionalBoardEditing();
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 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 (!editing) return null;
const {
isEditMode,
toggle,
prewarmEditor,
editorLoadState,
isPending,
isEnteringEditMode,
returnToLayoutSettings,
cancelLayoutEdit,
} = editing;
if (returnToLayoutSettings) {
return (
<>
<HeaderButton
onClick={() => saveBoard(board)}
onClick={() => void toggle()}
loading={isPending}
aria-label={tBoardSetting("section.layout.edit.save")}
>
@@ -217,7 +86,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
data-testid="board-edit-mode-toggle"
data-board-editor-preload-state={editorLoadState}
aria-busy={isPending || isEnteringEditMode}
aria-label={isEditMode ? (demoReadOnly ? t("exit") : t("save")) : t("enter")}
aria-label={editing.label}
aria-pressed={isEditMode}
>
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
@@ -225,57 +94,3 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
</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";
import {
Anchor,
Button,
Collapse,
ColorInput,
@@ -10,7 +9,6 @@ import {
Group,
InputWrapper,
isLightColor,
Select,
Slider,
Stack,
Text,
@@ -21,7 +19,7 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui";
import { BoardColorInput, CornerStylePicker, cornerStyleValues } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
@@ -39,7 +37,6 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
@@ -50,6 +47,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Stack gap="xs">
<BoardColorInput
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}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
@@ -57,10 +59,22 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Stack>
</Grid.Col>
<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 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 span={12}>
<Collapse expanded={showPreview}>
@@ -73,6 +87,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={tBoard("field.opacity.label")}>
<Slider
thumbLabel={tBoard("field.opacity.label")}
my={6}
min={0}
max={100}
@@ -86,6 +101,11 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Group align="end">
<ColorInput
label={tBoard("field.iconColor.label")}
eyeDropperButtonProps={{
"aria-label": tBoard("setting.section.appearance.pickColor", {
label: tBoard("field.iconColor.label"),
}),
}}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
@@ -102,18 +122,18 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
<Grid.Col span={12}>
<CornerStylePicker
compact
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
data={[
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
value={form.values.itemRadius}
labels={
Object.fromEntries(
cornerStyleValues.map((cornerStyle) => [cornerStyle, tBoard(`field.itemRadius.option.${cornerStyle}`)]),
) as Record<(typeof cornerStyleValues)[number], string>
}
onChange={(cornerStyle) => form.setFieldValue("itemRadius", cornerStyle)}
/>
</Grid.Col>
</Grid>
@@ -1,7 +1,7 @@
"use client";
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 { IconPhotoOff, IconUpload } from "@tabler/icons-react";
@@ -24,6 +24,7 @@ interface Props {
export const BackgroundSettingsContent = ({ form }: Props) => {
const tBoard = useI18n("board");
const tMedia = useI18n("media");
const { data: session } = useSession();
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">
<Autocomplete
flex={1}
leftSectionPointerEvents="all"
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ActionIcon type="button" variant="subtle" aria-label={tMedia("field.preview")}>
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
@@ -113,7 +115,15 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
>
{({ 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} />
</ActionIcon>
)}
@@ -1,70 +1,6 @@
.canvas {
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;
.preview {
width: 100%;
height: 100%;
overflow: hidden;
}
.tileLabel {
display: none;
max-width: 32rem;
min-width: 0;
max-height: 2.1em;
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;
margin-inline: auto;
}
@@ -1,17 +1,17 @@
"use client";
import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import { useMemo } from "react";
import { Badge, ColorSwatch, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
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 { FormValues } from "./_settings-form";
import classes from "./_layout-preview.module.css";
interface Props {
@@ -20,107 +20,55 @@ interface Props {
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
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 tSection = useI18n("section");
const t = useI18n();
const elements = projectBoardLayout(board, sourceLayout, layout);
const representativeWidth = getRepresentativeLayoutWidth(layout, layouts);
const largestRepresentativeWidth = Math.max(
...layouts.map((candidate) => getRepresentativeLayoutWidth(candidate, layouts)),
);
const previewWidth = `${(representativeWidth / largestRepresentativeWidth) * 100}%`;
const appsById = new Map(apps.map((app) => [app.id, app]));
const rootSections = board.sections
.filter((section) => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.xOffset - sectionB.xOffset);
const preview = useMemo(() => {
const appsById = new Map(apps.map((app) => [app.id, app]));
return {
layouts,
sections: board.sections,
items: board.items.map((item) => {
let iconUrl: string | undefined;
if (item.kind === "app" && typeof item.options.appId === "string")
iconUrl = appsById.get(item.options.appId)?.iconUrl;
return { ...item, iconUrl };
}),
};
}, [board, layouts, apps]);
return (
<Stack gap={6} align="center" w="100%">
<Group gap="xs" justify="center">
<Badge variant="light" color={layout.role === "mobile" ? "teal" : layout.role === "base" ? "blue" : "gray"}>
{representativeWidth}px
</Badge>
<Text size="xs">
{layout.columnCount} {tBoard("setting.section.layout.preview.columns")}
<Stack gap="xs" className={classes.preview} data-testid="board-layout-preview">
<Group justify="space-between" gap="xs">
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Badge variant="light" size="sm">
{Math.max(320, getRepresentativeLayoutWidth(layout, layouts))} px
</Badge>
</Group>
<Box w={{ base: "100%", md: `max(${previewWidth}, 14rem)` }} maw="100%" className={classes.canvas}>
{elements.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="xl">
{tBoard("setting.section.layout.preview.empty")}
</Text>
) : (
<Stack gap="xs">
{rootSections.map((section) => {
const sectionElements = elements.filter((element) => element.sectionId === section.id);
const columnCount = getBoardLaneColumnCount(layout, getRootSectionLane(section.xOffset));
if (sectionElements.length === 0 || columnCount === 0) return null;
return (
<Stack key={section.id} gap={4} className={classes.section}>
<Box
className={classes.grid}
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
>
{sectionElements.map((element) => {
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>
<BoardLayoutThumbnail preview={preview} label={t("canvasLabel")} canvas={{ layout, sourceLayout, settings }} />
<Group justify="space-between" gap="xs">
<Text size="xs" c="dimmed">
{settings.fixedScaling
? t("dimensions", { size: normalizeFixedItemSize(settings.fixedItemSize), columns: layout.columnCount })
: t("responsiveDimensions", { columns: layout.columnCount })}
</Text>
<Group gap={6}>
<Tooltip label={tBoard("field.primaryColor.label")}>
<ColorSwatch color={settings.primaryColor} size={16} aria-hidden />
</Tooltip>
<Tooltip label={tBoard("field.secondaryColor.label")}>
<ColorSwatch color={settings.secondaryColor} size={16} aria-hidden />
</Tooltip>
</Group>
</Group>
<Text size="xs" c="dimmed">
{t("description")}
</Text>
</Stack>
);
};
@@ -2,6 +2,54 @@
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 {
display: grid;
gap: 6px;
@@ -41,6 +89,7 @@
}
@media (prefers-reduced-motion: reduce) {
.fixedItemSizePreviewTile,
.gutterPreview {
transition: none;
}
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
@@ -12,6 +12,8 @@ import {
Input,
NumberInput,
Paper,
Select,
Alert,
Slider,
Stack,
Switch,
@@ -21,6 +23,7 @@ import {
import { IconEdit, IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { RouterOutputs } from "@homarr/api";
import { createId } from "@homarr/common";
import type { UseFormReturnType } from "@homarr/form";
import { showErrorNotification } from "@homarr/notifications";
@@ -30,6 +33,7 @@ import { InlineConfirmButton } from "@homarr/ui";
import { SectionCard } from "~/components/manage/section-card";
import type { Board } from "../../_types";
import { LayoutPreview } from "./_layout-preview";
import { ScalingSettings } from "./_scaling";
import type { FormValues } from "./_settings-form";
import classes from "./_layout.module.css";
@@ -38,11 +42,22 @@ interface Props {
form: UseFormReturnType<FormValues>;
isSaving: boolean;
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;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
export const LayoutSettingsContent = ({
board,
form,
isSaving,
saveSettingsAsync,
selectedLayoutId,
setSelectedLayoutId,
apps,
}: Props) => {
const tBoard = useI18n("board");
const tLayout = useI18n("layout");
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]) => {
setEditingLayoutId(layout.id);
@@ -106,10 +109,14 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
const activeLayoutId =
displayedLayouts.find(({ layout }) => layout.id === selectedLayoutId)?.layout.id ?? baseLayout?.id;
return (
<SectionCard title={tBoard("setting.section.layout.title")}>
<Stack gap="lg">
<ScalingSettings form={form} />
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2} maw="52rem">
<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);
if (insertionIndex === -1) insertionIndex = form.values.layouts.length;
form.insertListItem("layouts", newLayout, insertionIndex);
setSelectedLayoutId(newLayout.id);
}}
>
{tBoard("setting.section.layout.responsive.action.add")}
</Button>
</Group>
{displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
<Select
label={tBoard("setting.section.layout.preview.layout")}
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 (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
bg="transparent"
>
<Grid gap={{ base: "lg", xl: "xl" }} align="flex-start">
<Grid.Col span={{ base: 12, md: 6, lg: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper label={tLayout("field.columnCount.label")}>
<Slider
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
mt="xs"
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);
}}
{Object.entries(form.errors)
.filter(([key]) => key.startsWith("layouts."))
.map(([key, error]) => (
<Alert key={key} color="red">
{error}
</Alert>
))}
{displayedLayouts
.filter(({ layout }) => layout.id === activeLayoutId)
.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
return (
<Fieldset
key={layout.id}
legend={
<Group gap="xs">
<Text>{layout.name || tBoard(`setting.section.layout.role.${layout.role}` as never)}</Text>
<Badge size="sm" variant="default">
{tBoard(`setting.section.layout.role.${layout.role}` as never)}
</Badge>
</Group>
}
bg="transparent"
>
<Grid gap="xl" align="flex-start">
<Grid.Col span={{ base: 12, md: 4 }}>
<Stack gap="md">
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={tCommon("field.name")} />
<Input.Wrapper
label={tLayout("field.columnCount.label")}
description={tLayout("field.columnCount.description")}
>
<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>
<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}
/>
</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)}
/>
{layout.role !== "mobile" && (
<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)
}
/>
)}
</Stack>
</Grid.Col>
)}
</Grid>
<Grid.Col span={{ base: 12, md: 8 }} className={classes.layoutPreview}>
{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 gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</Button>
)}
<Group justify="space-between" mt="lg" gap="sm" wrap="wrap">
<Group gap="xs">
{layout.role !== "base" && persistedLayout && (
<InlineConfirmButton
type="button"
variant="default"
leftSection={<IconRefresh size={16} color="var(--mantine-color-red-5)" />}
loading={resettingLayoutId === layout.id}
disabled={isSaving}
onConfirm={() => handleReset(layout)}
confirmLabel={tCommon("action.confirm")}
>
{tBoard("setting.section.layout.reset.action")}
</InlineConfirmButton>
)}
{layout.role === "custom" && (
<Button
type="button"
variant="subtle"
color="red"
disabled={isSaving}
onClick={() => {
form.setFieldValue(
"layouts",
form.values.layouts.filter((candidate) => candidate.id !== layout.id),
);
}}
>
{tCommon("action.remove")}
</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>
<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>
</Fieldset>
);
})}
</Fieldset>
);
})}
</Stack>
</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,12 +1,16 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import { useEffect, useMemo, useRef, useState } from "react";
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 { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
@@ -19,8 +23,7 @@ import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { useUnsavedChangesGuard } from "~/components/manage/use-unsaved-changes-guard";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { AppearancePlayground } from "./_appearance-playground";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
@@ -28,6 +31,7 @@ import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
import classes from "./_settings-form.module.css";
const boardSettingsFormSchema = boardSavePartialSettingsSchema
.extend({
@@ -58,6 +62,8 @@ const PARTIAL_FORM_KEYS = [
"itemRadius",
"customCss",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
@@ -76,6 +82,8 @@ const buildInitialValues = (board: Board): FormValues => ({
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
fixedScaling: board.fixedScaling ?? false,
fixedItemSize: normalizeFixedItemSize(board.fixedItemSize ?? ""),
layouts: normalizeMobileLayoutGutters(board.layouts),
});
@@ -93,6 +101,21 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(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, {
initialValues: buildInitialValues(board),
@@ -107,7 +130,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
useEffect(() => {
return () => {
@@ -119,7 +142,22 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
}, [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 validation = form.validate();
if (validation.hasErrors) {
revealValidationErrors(validation.errors);
return null;
}
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
@@ -164,6 +202,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
};
const handleDiscard = () => {
form.clearErrors();
form.setValues(initialValuesRef.current);
form.resetDirty();
updateBoard((previous) => ({
@@ -176,45 +215,101 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void saveSettingsAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<Tabs
value={activeTab}
onChange={setActiveTab}
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
board={board}
form={form}
isSaving={isPending}
saveSettingsAsync={() => saveSettingsAsync(form.values)}
selectedLayoutId={selectedLayoutId}
setSelectedLayoutId={setSelectedLayoutId}
apps={apps}
/>
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
</Tabs.Panel>
<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} />
<BehaviorSettingsContent form={form} />
</Tabs.Panel>
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("action.discard")}
</Button>
<Button loading={isPending} type="submit">
{t("action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</form>
{hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<Tabs.Panel value="access">
<Stack gap="lg">
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
<DangerZoneSettingsContent hideVisibility={hideVisibility} />
</Stack>
</Tabs.Panel>
)}
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
</Stack>
</Tabs>
);
};
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
// https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
@@ -20,6 +20,7 @@ import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProviders } from "./_providers";
import { BoardEditingProvider } from "./(content)/_editing-provider";
import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
@@ -106,20 +107,25 @@ export const createBoardLayout = <TParams extends Params>({
<BoardReadyProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<ModalProvider>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
<BoardEditingProvider
enabled={Boolean(headerBoardEditAction) && hasChangeAccess}
demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY}
>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogo size={appShellLogoHeight} />}
logoWithTitle={<BoardLogoWithTitle size="md" />}
actions={headerActions}
boardEditAction={headerBoardEditAction}
boardSettingsAction={headerBoardSettingsAction}
hasNavigation={false}
/>
<AppShellMain data-advanced-focus-background>{children}</AppShellMain>
</ClientShell>
</BoardTourGate>
</BoardEditingProvider>
</ModalProvider>
</BoardMantineProvider>
</BoardReadyProvider>
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -80,7 +81,7 @@ export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEn
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -27,6 +27,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
@@ -125,7 +126,7 @@ export const UserGeneralSettingsForm = ({
initialValues,
});
useUnsavedChangesGuard(form.isDirty());
useUnsavedChangesGuard(form.isDirty(), { guardBeforeUnload: env.NODE_ENV !== "development" });
const weekDays = useMemo(() => dayjs.weekdays(false), []);
@@ -11,6 +11,7 @@ import {
CopyButton,
Group,
InputWrapper,
NumberInput,
Select,
SimpleGrid,
Skeleton,
@@ -29,6 +30,7 @@ import {
IconCheck,
IconCopy,
IconFileTypeCss,
IconLayoutGrid,
IconPalette,
IconPhoto,
IconRefresh,
@@ -38,10 +40,17 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
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 { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { normalizeFixedItemSize } from "~/components/board/layout/scaling";
import {
getAssistantBoardSettingsResult,
@@ -85,6 +94,8 @@ const normalizeFormValues = (
iconColor: values.iconColor ?? "",
itemRadius: values.itemRadius,
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} />}>
{t("tabs.css")}
</Tabs.Tab>
<Tabs.Tab value="layout" leftSection={<IconLayoutGrid size={15} />}>
{t("tabs.layout")}
</Tabs.Tab>
<Tabs.Tab value="appearance" leftSection={<IconPalette size={15} />}>
{t("tabs.appearance")}
</Tabs.Tab>
@@ -329,6 +343,29 @@ const BoardSettingsForm = ({
</Stack>
</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">
<Stack gap="md">
<SimpleGrid cols={{ base: 1, sm: 2 }}>
@@ -16,6 +16,8 @@ export const assistantBoardSettingKeys = [
"iconColor",
"itemRadius",
"disableStatus",
"fixedScaling",
"fixedItemSize",
] as const satisfies readonly (keyof AssistantBoardSettingsChanges)[];
const comparableValue = (value: unknown) => (value === null ? "" : value);
@@ -54,12 +56,14 @@ const backgroundKeys = new Set<keyof AssistantBoardSettingsChanges>([
"backgroundImageRepeat",
"backgroundImageSize",
]);
const layoutKeys = new Set<keyof AssistantBoardSettingsChanges>(["fixedScaling", "fixedItemSize"]);
export const getAssistantBoardSettingsDefaultTab = (
changes: AssistantBoardSettingsChanges,
): "general" | "appearance" | "background" | "css" => {
): "general" | "layout" | "appearance" | "background" | "css" => {
if (changes.customCss !== undefined) return "css";
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) => backgroundKeys.has(key))) return "background";
return "general";
@@ -1,6 +1,8 @@
import { z } from "zod/v4";
import {
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
@@ -48,6 +50,17 @@ export const assistantBoardSettingsChangesSchema = z
iconColor: boardColorSchema.nullable().optional(),
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]).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.");
@@ -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;
color: var(--mantine-color-dimmed);
background:
linear-gradient(
to top,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 55%
),
linear-gradient(to top, color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent), transparent 55%),
var(--mantine-color-body);
}
@@ -84,3 +80,60 @@
gap: rem(1px);
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 { 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";
interface BoardLayoutThumbnailProps {
@@ -13,6 +15,7 @@ interface BoardLayoutThumbnailProps {
label: string;
previewRowLimit?: number;
className?: string;
canvas?: BoardLayoutThumbnailCanvasProps["canvas"];
}
const compactBoardLayoutThumbnailRows = 12;
@@ -23,7 +26,11 @@ export const BoardLayoutThumbnail = ({
label,
previewRowLimit = compactBoardLayoutThumbnailRows,
className,
canvas,
}: BoardLayoutThumbnailProps) => {
if (preview && canvas) {
return <BoardLayoutThumbnailCanvas preview={preview} label={label} canvas={canvas} className={className} />;
}
const canvasClassName = [classes.canvas, className].filter(Boolean).join(" ");
const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
@@ -1,4 +1,5 @@
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 { ContainerSectionMockBuilder } from "./container-section-mock";
@@ -27,6 +28,8 @@ export class BoardMockBuilder {
name: "board",
opacity: 100,
isPublic: true,
fixedScaling: false,
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
disableStatus: false,
customCss: "",
creatorId: createId(),
@@ -18,6 +18,11 @@
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 {
@mixin dark {
--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;
- 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);
- 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
use a scale-compensated inset so their painted size and content measurements
match the legacy responsive grid. The visual canvas always fits the available
width, every root lane keeps at least one viewport of height, and edit-mode
previews extend it downward as needed.
match the legacy responsive grid. Responsive scaling derives the zoom from the
available width. Fixed scaling derives it from the configured painted `1 x 1`
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:
@@ -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_GAP = 12;
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;
@@ -10,6 +10,36 @@
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 {
position: relative;
}
@@ -17,42 +47,4 @@
.canvas {
position: absolute;
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";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { CSSProperties, PropsWithChildren } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { Box } from "@mantine/core";
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 appearanceClasses from "./canvas-appearance.module.css";
import scrollbarClasses from "./canvas-scrollbar.module.css";
const BoardCanvasScaleContext = createContext(1);
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
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;
};
export const useBoardCanvasViewportHeight = () => useContext(BoardCanvasViewportHeightContext);
interface ScaledBoardCanvasProps {
logicalWidth: number;
initialLogicalHeight: number;
initialAvailableWidth: number;
scaling: BoardScaling;
label: string;
}
/**
* Scales the complete board as one surface while keeping widget layout in
* fixed logical pixels. The complete canvas always fits its viewport so board
* content can never widen the document or require horizontal scrolling.
* fixed logical pixels. Responsive scaling fits the canvas to its viewport;
* fixed scaling contains overflow in a keyboard-scrollable canvas region.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
initialLogicalHeight,
initialAvailableWidth,
scaling,
label,
children,
}: 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 [isHydrated, setIsHydrated] = useState(false);
@@ -53,40 +50,58 @@ export const ScaledBoardCanvas = ({
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
const scale = useMemo(
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
[logicalWidth, resolvedAvailableWidth],
);
const isFixedScaling = scaling.mode === "fixed";
const {
scale,
width: visualWidth,
centered,
overflows,
} = getBoardCanvasMetrics(scaling, resolvedAvailableWidth, logicalWidth);
const uiScale = calculateBoardUiScale(scale);
const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
return (
<Box
component="section"
ref={viewportRef}
className={classes.viewport}
className={`${classes.viewport} ${isFixedScaling ? scrollbarClasses.scrollbar : ""}`}
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scaling={scaling.mode}
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-width={initialAvailableWidth}
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
ref={canvasRef}
className={classes.canvas}
className={`${classes.canvas} ${appearanceClasses.appearance}`}
style={{
"--board-grid-card-inset": `${BOARD_GRID_ITEM_INSET}px`,
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
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>
@@ -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 { 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", () => {
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", () => {
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 {
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]) {
@@ -10,6 +10,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import {
BOARD_GRID_ITEM_INSET,
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
getGridRowCountForVisualHeight,
@@ -18,7 +19,8 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} 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 { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -50,6 +52,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const canvasViewportHeight = useBoardCanvasViewportHeight();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -130,7 +133,11 @@ export const SectionGrid = ({
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
[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 rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
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
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
// card and visually spills past its right/bottom edges.
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
// 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
@@ -342,11 +349,11 @@ const INTERACTIVE_GRID_SELECTOR =
'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 useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number, canvasViewportHeight: number | null) => {
const [visualHeight, setVisualHeight] = useState(0);
useEffect(() => {
if (!enabled) return;
if (!enabled || canvasViewportHeight !== null) return;
const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
update();
window.addEventListener("resize", update, { passive: true });
@@ -355,9 +362,10 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
window.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<
@@ -1,9 +1,14 @@
interface UnsavedChangesGuardOptions {
isDirty(): boolean;
confirmNavigation(href: string): void;
guardBeforeUnload?: boolean;
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
export function registerUnsavedChangesGuard({
isDirty,
confirmNavigation,
guardBeforeUnload = true,
}: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
@@ -17,6 +22,7 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
const current = new URL(window.location.href);
if (destination.origin !== current.origin) return;
if (destination.pathname === current.pathname && destination.search === current.search) {
currentHref = `${destination.pathname}${destination.search}${destination.hash}`;
return;
}
@@ -48,11 +54,11 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
if (guardBeforeUnload) window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
if (guardBeforeUnload) window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -7,7 +7,11 @@ import { useConfirmModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { registerUnsavedChangesGuard } from "./unsaved-changes-guard";
export function useUnsavedChangesGuard(isDirty: boolean) {
interface UnsavedChangesGuardOptions {
guardBeforeUnload?: boolean;
}
export function useUnsavedChangesGuard(isDirty: boolean, options: UnsavedChangesGuardOptions = {}) {
const tConfirmLeave = useI18n("board.action.edit.confirmLeave");
const tCommon = useI18n("common");
const router = useRouter();
@@ -39,7 +43,8 @@ export function useUnsavedChangesGuard(isDirty: boolean) {
registerUnsavedChangesGuard({
isDirty: () => dirtyRef.current,
confirmNavigation: (href) => confirmNavigationRef.current(href),
guardBeforeUnload: options.guardBeforeUnload,
}),
[],
[options.guardBeforeUnload],
);
}
@@ -13,6 +13,8 @@ import {
IconSettings,
IconRobot,
IconTool,
IconPencil,
IconDeviceFloppy,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -25,6 +27,7 @@ import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { useOptionalBoardEditing } from "./board/board-editing-context";
import { useOptionalHomarrAssistant } from "./assistant/assistant-context";
import type { BoardSwitcherControls } from "./board/board-switcher";
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 session = useSession();
const board = useOptionalBoard();
const editing = useOptionalBoardEditing();
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
const { logoutUrl } = useAuthContext();
@@ -81,6 +85,18 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
{tBoard("action.settings")}
</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
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
+2 -2
View File
@@ -89,7 +89,7 @@ describe("Onboarding", () => {
try {
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`);
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
const start = page.getByRole("button", { name: "Get started" });
await start.focus();
await start.press("Enter");
@@ -356,7 +356,7 @@ describe("Onboarding", () => {
);
await loginAsync({ page, baseUrl, credentials, destination: "/init" });
await page.getByRole("heading", { name: "Make home feel organized." }).waitFor();
await page.getByRole("heading", { name: "Welcome home." }).waitFor();
await page.getByRole("button", { name: "Get started" }).click();
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
+10 -4
View File
@@ -42,6 +42,8 @@ import {
getWidgetIntegrationIssueMessage,
getPermissionsWithChildren,
getPermissionsWithParents,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
@@ -1142,8 +1144,7 @@ export const boardRouter = createTRPCRouter({
.meta({
mcp: {
enabled: true,
description:
"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",
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`,
},
})
.input(z.object({ id: z.string() }))
@@ -1170,6 +1171,8 @@ export const boardRouter = createTRPCRouter({
customCss: true,
iconColor: true,
itemRadius: true,
fixedScaling: true,
fixedItemSize: true,
disableStatus: true,
},
where: boardWhere,
@@ -1526,8 +1529,7 @@ export const boardRouter = createTRPCRouter({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"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",
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`,
},
})
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
@@ -1557,6 +1559,10 @@ export const boardRouter = createTRPCRouter({
iconColor: input.iconColor,
itemRadius: input.itemRadius,
// scaling settings
fixedScaling: input.fixedScaling,
fixedItemSize: input.fixedItemSize,
// custom css
customCss: input.customCss,
@@ -29,6 +29,7 @@ import {
serverSettings,
} from "@homarr/db/schema";
import {
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
everyoneGroup,
extractContainerImageName,
@@ -388,6 +389,8 @@ export const onboardRouter = createTRPCRouter({
customCss: null,
iconColor: null,
itemRadius: input.board.itemRadius,
fixedScaling: false,
fixedItemSize: BOARD_FIXED_ITEM_SIZE_DEFAULT,
disableStatus: false,
sections: [],
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,
"tag": "0049_add_byte_unit_system",
"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,
"tag": "0015_add_byte_unit_system",
"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,
"tag": "0047_add_byte_unit_system",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1788845055278,
"tag": "0048_add_fixed_board_scaling",
"breakpoints": true
}
]
}
+3
View File
@@ -23,6 +23,7 @@ import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
} from "@homarr/definitions";
import type {
@@ -301,6 +302,8 @@ export const boards = mysqlTable("board", {
customCss: text(),
iconColor: text(),
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(),
});
+3
View File
@@ -21,6 +21,7 @@ import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
} from "@homarr/definitions";
import type {
@@ -299,6 +300,8 @@ export const boards = pgTable("board", {
customCss: text(),
iconColor: text(),
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(),
});
+3
View File
@@ -9,6 +9,7 @@ import {
backgroundImageAttachments,
backgroundImageRepeats,
backgroundImageSizes,
BOARD_FIXED_ITEM_SIZE_DEFAULT,
emptySuperJSON,
} from "@homarr/definitions";
import type {
@@ -284,6 +285,8 @@ export const boards = sqliteTable("board", {
customCss: text(),
iconColor: text(),
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(),
});
+4
View File
@@ -10,6 +10,10 @@ export const backgroundImageSizes = createDefinition(["cover", "contain"], {
});
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 BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
@@ -12,13 +12,13 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import { IconBulb, IconListCheck, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { selectGridCols, SelectableCard } from "@homarr/ui";
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -106,6 +106,17 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
return (
<Stack gap="md">
<FloatingTip
opened={multiSelectAvailable}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{t("tips.multiSelectApps")}
</FloatingTip>
{/* Top Search Input */}
<Stack gap={6}>
<Input
+35 -4
View File
@@ -27,11 +27,11 @@
"review": "Review"
},
"welcome": {
"title": "Make home feel organized.",
"title": "Welcome home.",
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
"start": "Get started",
"restore": "Restore backup",
"restoreTitle": "Restore this Homarr",
"restoreTitle": "Restore from backup",
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
"errorTitle": "Setup could not start",
"errorDescription": "Your setup choices are still here. Try again, or reload the page if this continues."
@@ -5430,6 +5430,16 @@
"label": "Disable app status",
"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": {
"label": "Column count"
},
@@ -5455,12 +5465,22 @@
"setting": {
"title": "Settings for {boardName} board",
"section": {
"navigation": "Board settings",
"advanced": "Advanced",
"validationError": "Check the highlighted settings before saving. Layouts with errors are marked in the layout selector.",
"general": {
"title": "General",
"unrecognizedLink": "The provided link is not recognized and won't preview, it might still work."
},
"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": {
"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.",
@@ -5480,6 +5500,12 @@
"breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint."
},
"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",
"empty": "This layout is empty",
"dynamicSection": "Dynamic section",
@@ -5507,7 +5533,10 @@
"title": "Background"
},
"appearance": {
"title": "Appearance"
"title": "Appearance",
"showShades": "Show color shades",
"hideShades": "Hide color shades",
"pickColor": "Pick {label} from the screen"
},
"customCss": {
"title": "Custom css"
@@ -5607,7 +5636,8 @@
"layout": {
"field": {
"columnCount": {
"label": "Column count"
"label": "Column count",
"description": "Maximum total columns in this layout, including fixed sidebars."
},
"breakpoint": {
"label": "Breakpoint",
@@ -8032,6 +8062,7 @@
"resetAll": "Reset all changes",
"tabs": {
"css": "Custom CSS",
"layout": "Layout",
"appearance": "Appearance",
"background": "Background",
"general": "General"
+4
View File
@@ -6,6 +6,8 @@ import {
backgroundImageSizes,
boardPermissions,
layoutRoles,
BOARD_FIXED_ITEM_SIZE_MAX,
BOARD_FIXED_ITEM_SIZE_MIN,
widgetKinds,
} from "@homarr/definitions";
@@ -68,6 +70,8 @@ export const boardSavePartialSettingsSchema = z
customCss: z.string().max(16384),
iconColor: hexColorNullableSchema,
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(),
})
.partial();