Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc24e9091 | ||
|
|
9256a432f9 | ||
|
|
bed69c7a92 | ||
|
|
6f62f3ca43 | ||
|
|
f23d197a35 | ||
|
|
5887735bd5 | ||
|
|
6db134cf15 | ||
|
|
ecc1009e23 | ||
|
|
9d857377c1 | ||
|
|
923905c529 |
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.
|
||||
|
||||

|
||||

|
||||
|
||||
### Goodbye Groups and sections, bonjour Containers
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ import { bookmarksWidget } from ".";
|
||||
|
||||
The Bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. URLs do not require an App record; Homarr
|
||||
uses the site's favicon when available. Choose adaptive, compact-grid, or other layouts from the widget configuration.
|
||||
Adaptive, vertical, and grid layouts keep larger cards when the configured bookmarks fit, then reduce card height and
|
||||
spacing to show as many bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with
|
||||
narrower cards in one-row widgets. Horizontal, compact-grid, and icon-only layouts keep their gaps tight. Compact grid
|
||||
also preserves its configured columns when the dashboard is scaled.
|
||||
Vertical uses consistent compact rows and keeps bookmark names visible whenever the widget is wide enough, regardless
|
||||
of how many bookmarks are configured. Adaptive and grid layouts reduce card height and spacing to show as many
|
||||
bookmarks as possible before scrolling. Horizontal uses a compact, centered scrolling row with narrower cards in
|
||||
one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep their gaps tight. Compact grid also
|
||||
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
|
||||
visual density when a low-column dashboard is enlarged to fill the screen.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const bookmarksWidget: WidgetDefinition = {
|
||||
{
|
||||
name: "Layout",
|
||||
description:
|
||||
"How bookmarks use the available widget space. Layouts stay compact and reduce card size when needed.",
|
||||
"How bookmarks use the available widget space. Vertical keeps compact rows and stable title visibility.",
|
||||
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||
defaultValue: "Adaptive",
|
||||
},
|
||||
|
||||
+8
@@ -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);
|
||||
|
||||
|
||||
+2
-1
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -144,9 +145,21 @@ export default function BookmarksWidget({
|
||||
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
|
||||
|
||||
const isTight = responsiveHeight < 120;
|
||||
let compactStyle: CSSProperties | undefined;
|
||||
if (!advanced && Number.isFinite(displayScale) && displayScale > 1) {
|
||||
// Low-column boards zoom the canvas above 100%. Keep bookmark controls at their intended physical size.
|
||||
// The spacing and font tokens are rebound here because their inherited values resolve at the canvas level.
|
||||
compactStyle = {
|
||||
"--board-canvas-ui-scale": 1 / displayScale,
|
||||
"--mantine-scale": 1 / displayScale,
|
||||
"--mantine-font-size-xxs": "calc(0.6875rem * var(--board-canvas-ui-scale))",
|
||||
"--mantine-spacing-sm": "calc(0.75rem * var(--board-canvas-ui-scale))",
|
||||
"--mantine-spacing-xs": "calc(0.625rem * var(--board-canvas-ui-scale))",
|
||||
} as CSSProperties;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" mih={0} gap={isTight ? 6 : "sm"} p={isTight ? 6 : "sm"}>
|
||||
<Stack h="100%" mih={0} gap={isTight ? 6 : "sm"} p={isTight ? 6 : "sm"} style={compactStyle}>
|
||||
{options.title.length > 0 ? (
|
||||
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
|
||||
{options.title}
|
||||
|
||||
@@ -40,7 +40,8 @@ export const getBookmarkCardDisplay = ({
|
||||
orientation = "horizontal";
|
||||
showTitle = true;
|
||||
}
|
||||
const showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||
let showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||
if (!advanced && plan.itemHeight <= 32 && showTitle) showHostname = false;
|
||||
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
|
||||
|
||||
return { orientation, showHostname, showIcon, showTitle };
|
||||
@@ -246,23 +247,15 @@ export const getBookmarkDisplayPlan = ({
|
||||
}
|
||||
|
||||
if (layout === "column") {
|
||||
const densitySettings = getBookmarkDensitySettings({
|
||||
columns: 1,
|
||||
gap,
|
||||
height: normalizedHeight,
|
||||
heightProperty: "columnItemHeight",
|
||||
itemCount: count,
|
||||
preferredSettings: heightSettings,
|
||||
});
|
||||
return {
|
||||
columns: 1,
|
||||
horizontalScroll: false,
|
||||
itemGap: getBookmarkDensityGap(gap, densitySettings),
|
||||
itemHeight: densitySettings.columnItemHeight,
|
||||
itemGap: Math.min(gap, 4),
|
||||
itemHeight: 32,
|
||||
itemWidth: width,
|
||||
orientation: "horizontal",
|
||||
showHostname: widthSettings.showHostname && densitySettings.showHostname,
|
||||
showTitle: widthSettings.showTitle && densitySettings.showTitle,
|
||||
showHostname: widthSettings.showHostname,
|
||||
showTitle: widthSettings.showTitle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user