feat: add fixed board scaling
This commit is contained in:
1 parent
9d857377c1
commit
ecc1009e23
36 files changed
+8654
-41
No files matched your search
@@ -62,6 +62,11 @@ Each board has exactly one Mobile and one Base layout. Mobile uses breakpoint `0
|
||||
layouts can use any other breakpoint, and Homarr selects the highest breakpoint that fits the viewport. Preserve layout
|
||||
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 `100` through `400`. 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,13 @@ 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 every `1 × 1` tile at the exact configured pixel size instead. A fixed dashboard is
|
||||
centred when it fits and scrolls both horizontally and vertically when it does not.
|
||||
|
||||
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
|
||||
|
||||
@@ -59,7 +63,7 @@ click remains available as a shortcut.
|
||||
Board settings cover:
|
||||
|
||||
- **General:** page title, browser title, logo, and favicon.
|
||||
- **Layout:** responsive layouts, breakpoints, columns, and rails.
|
||||
- **Layout:** fitted or fixed scaling, tile size, responsive layouts, breakpoints, maximum 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.
|
||||
|
||||
+8
@@ -2151,6 +2151,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"fixedScaling": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fixedItemSize": {
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 400
|
||||
},
|
||||
"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)
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalGridSize,
|
||||
LOGICAL_GRID_GAP,
|
||||
getRootSectionForLane,
|
||||
} from "~/components/board/layout";
|
||||
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
|
||||
@@ -33,6 +32,7 @@ import { BoardSelectionToolbar } from "~/components/board/selection/board-select
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
const BOARD_LANE_GAP = 24;
|
||||
|
||||
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
|
||||
<GridEditorRegistryProvider>
|
||||
@@ -63,11 +63,11 @@ export const ClientBoard = () => {
|
||||
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 logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * BOARD_LANE_GAP;
|
||||
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 fixedItemSize = board.fixedScaling ? board.fixedItemSize : undefined;
|
||||
const gridTemplateColumns = [
|
||||
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
|
||||
`${getLogicalGridSize(mainColumnCount)}px`,
|
||||
@@ -90,10 +90,11 @@ export const ClientBoard = () => {
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
fixedItemSize={fixedItemSize}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
<div className={classes.columns} style={{ gap: BOARD_LANE_GAP, gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
.scalingSettings,
|
||||
.gutterSettings {
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 55%, transparent);
|
||||
}
|
||||
|
||||
.fixedItemSizePreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
margin: 0;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.fixedItemSizePreviewTile {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
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);
|
||||
transition:
|
||||
width 180ms ease-out,
|
||||
height 180ms ease-out;
|
||||
}
|
||||
|
||||
.gutterPreview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
@@ -41,6 +65,7 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fixedItemSizePreviewTile,
|
||||
.gutterPreview {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { IconEdit, IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { BOARD_FIXED_ITEM_SIZE_MAX, BOARD_FIXED_ITEM_SIZE_MIN } from "@homarr/definitions";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -106,10 +107,55 @@ 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 fixedItemPreviewSize =
|
||||
Math.min(BOARD_FIXED_ITEM_SIZE_MAX, Math.max(BOARD_FIXED_ITEM_SIZE_MIN, form.values.fixedItemSize)) / 4;
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("setting.section.layout.title")}>
|
||||
<Stack gap="lg">
|
||||
<Paper className={classes.scalingSettings} p="md" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>{tBoard("setting.section.layout.scaling.title")}</Text>
|
||||
<Switch
|
||||
label={tBoard("field.fixedScaling.label")}
|
||||
description={tBoard("field.fixedScaling.description")}
|
||||
{...form.getInputProps("fixedScaling", { type: "checkbox" })}
|
||||
/>
|
||||
<Group align="center" justify="space-between" gap="xl" wrap="wrap">
|
||||
<NumberInput
|
||||
label={tBoard("field.fixedItemSize.label")}
|
||||
description={tBoard("field.fixedItemSize.description")}
|
||||
min={BOARD_FIXED_ITEM_SIZE_MIN}
|
||||
max={BOARD_FIXED_ITEM_SIZE_MAX}
|
||||
step={10}
|
||||
allowDecimal={false}
|
||||
suffix=" px"
|
||||
w={{ base: "100%", sm: 240 }}
|
||||
{...form.getInputProps("fixedItemSize")}
|
||||
/>
|
||||
<Box
|
||||
component="figure"
|
||||
className={classes.fixedItemSizePreview}
|
||||
aria-label={tBoard("field.fixedItemSize.previewLabel", { size: form.values.fixedItemSize })}
|
||||
>
|
||||
<Box
|
||||
className={classes.fixedItemSizePreviewTile}
|
||||
w={fixedItemPreviewSize}
|
||||
h={fixedItemPreviewSize}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Text size="xs" fw={600}>
|
||||
1 × 1
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" fw={500}>
|
||||
{tBoard("field.fixedItemSize.previewValue", { size: form.values.fixedItemSize })}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
<Text fw={500}>{tBoard("setting.section.layout.responsive.title")}</Text>
|
||||
@@ -160,7 +206,10 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
|
||||
<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")}>
|
||||
<Input.Wrapper
|
||||
label={tLayout("field.columnCount.label")}
|
||||
description={tLayout("field.columnCount.description")}
|
||||
>
|
||||
<Slider
|
||||
thumbLabel={`${tLayout("field.columnCount.label")} — ${layout.name}`}
|
||||
mt="xs"
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { BOARD_FIXED_ITEM_SIZE_DEFAULT } from "@homarr/definitions";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
@@ -59,6 +60,8 @@ const PARTIAL_FORM_KEYS = [
|
||||
"itemRadius",
|
||||
"customCss",
|
||||
"disableStatus",
|
||||
"fixedScaling",
|
||||
"fixedItemSize",
|
||||
] as const;
|
||||
|
||||
const buildInitialValues = (board: Board): FormValues => ({
|
||||
@@ -77,6 +80,8 @@ const buildInitialValues = (board: Board): FormValues => ({
|
||||
itemRadius: board.itemRadius,
|
||||
customCss: board.customCss ?? "",
|
||||
disableStatus: board.disableStatus,
|
||||
fixedScaling: board.fixedScaling ?? false,
|
||||
fixedItemSize: board.fixedItemSize ?? BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||
layouts: normalizeMobileLayoutGutters(board.layouts),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,7 +40,14 @@ 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_DEFAULT,
|
||||
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";
|
||||
@@ -85,6 +94,8 @@ const normalizeFormValues = (
|
||||
iconColor: values.iconColor ?? "",
|
||||
itemRadius: values.itemRadius,
|
||||
disableStatus: values.disableStatus,
|
||||
fixedScaling: values.fixedScaling ?? false,
|
||||
fixedItemSize: values.fixedItemSize ?? BOARD_FIXED_ITEM_SIZE_DEFAULT,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,26 @@ 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")}
|
||||
/>
|
||||
</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.");
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -5,13 +5,15 @@ 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.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
|
||||
@@ -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,76 @@
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"] {
|
||||
--board-canvas-fixed-viewport-height: calc(
|
||||
100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-padding, 1rem) -
|
||||
var(--app-shell-footer-offset, 0px) - var(--app-shell-padding, 1rem)
|
||||
);
|
||||
|
||||
height: var(--board-canvas-fixed-viewport-height);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-track,
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]:hover::-webkit-scrollbar-thumb,
|
||||
.viewport[data-canvas-scaling="fixed"]:focus-visible::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent);
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--mantine-color-dimmed) 70%, transparent);
|
||||
}
|
||||
|
||||
@supports (-moz-appearance: none) {
|
||||
.viewport[data-canvas-scaling="fixed"] {
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.viewport[data-canvas-scaling="fixed"]:hover,
|
||||
.viewport[data-canvas-scaling="fixed"]:focus-visible {
|
||||
scrollbar-color: color-mix(in srgb, var(--mantine-color-dimmed) 45%, transparent) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.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"] .sizer {
|
||||
/* Leave room for edit labels and controls that intentionally sit above first-row tiles. */
|
||||
margin-block-start: calc(2rem * var(--board-canvas-scale, 1));
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { CSSProperties, PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
const BoardCanvasScaleContext = createContext(1);
|
||||
const BoardCanvasViewportHeightContext = createContext<number | null>(null);
|
||||
|
||||
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
|
||||
export const useBoardCanvasViewportHeight = () => useContext(BoardCanvasViewportHeightContext);
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
@@ -18,6 +21,14 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
|
||||
return availableWidth / logicalWidth;
|
||||
};
|
||||
|
||||
export const calculateFixedBoardCanvasScale = (fixedItemSize: number) => {
|
||||
if (!Number.isFinite(fixedItemSize) || fixedItemSize <= 0) return 1;
|
||||
|
||||
// The configured size is the painted card, after the two fixed physical insets
|
||||
// have been removed from its scaled logical grid footprint.
|
||||
return (fixedItemSize + 2 * BOARD_GRID_ITEM_INSET) / LOGICAL_GRID_PITCH;
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||
|
||||
@@ -28,22 +39,24 @@ interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
fixedItemSize?: number;
|
||||
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,
|
||||
fixedItemSize,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
||||
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useElementSize<HTMLDivElement>();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
@@ -53,14 +66,17 @@ 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 = fixedItemSize !== undefined;
|
||||
const scale = useMemo(() => {
|
||||
if (fixedItemSize !== undefined) return calculateFixedBoardCanvasScale(fixedItemSize);
|
||||
|
||||
return calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth);
|
||||
}, [fixedItemSize, logicalWidth, resolvedAvailableWidth]);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const inverseScale = scale > 0 ? 1 / scale : 1;
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
const shouldCenterCanvas = isFixedScaling && visualWidth <= resolvedAvailableWidth;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -69,24 +85,41 @@ export const ScaledBoardCanvas = ({
|
||||
className={classes.viewport}
|
||||
data-testid="board-canvas"
|
||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||
data-canvas-scaling={isFixedScaling ? "fixed" : "responsive"}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-centered={shouldCenterCanvas ? "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}
|
||||
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>
|
||||
|
||||
@@ -1250,7 +1250,7 @@ const configureEditorPlugins = (defaults: Plugins<DragDropManager>) =>
|
||||
dropAnimation: null,
|
||||
keyboardTransition: { duration: 100 },
|
||||
});
|
||||
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0, y: 0.16 } });
|
||||
if (plugin === AutoScroller) return AutoScroller.configure({ acceleration: 24, threshold: { x: 0.16, y: 0.16 } });
|
||||
return plugin;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,11 @@ import {
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
|
||||
import {
|
||||
calculateBoardUiScale,
|
||||
useBoardCanvasScale,
|
||||
useBoardCanvasViewportHeight,
|
||||
} from "~/components/board/layout/scaled-board-canvas";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -50,6 +55,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 +136,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 +160,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 +352,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 +365,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<
|
||||
|
||||
@@ -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 = 100;
|
||||
export const BOARD_FIXED_ITEM_SIZE_MAX = 400;
|
||||
|
||||
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
|
||||
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
|
||||
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
|
||||
|
||||
@@ -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 every tile 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} x {size} px"
|
||||
},
|
||||
"columnCount": {
|
||||
"label": "Column count"
|
||||
},
|
||||
@@ -5461,6 +5471,9 @@
|
||||
},
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
"scaling": {
|
||||
"title": "Scaling"
|
||||
},
|
||||
"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.",
|
||||
@@ -5607,7 +5620,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 +8046,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();
|
||||
|
||||
Reference in new issue
Block a user