feat: refine board settings and compact previews
This commit is contained in:
1 parent
ecc1009e23
commit
6db134cf15
27 files changed
+1203
-550
No files matched your search
@@ -28,7 +28,7 @@ creation. Additional layouts use unique breakpoints. Homarr selects the layout w
|
||||
the current viewport.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
The column count is the maximum total for that layout, including any fixed sidebars. For example, a Base layout with
|
||||
@@ -60,14 +60,29 @@ 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:** 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.
|
||||
- **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 **100–400 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
getLogicalGridSize,
|
||||
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";
|
||||
@@ -32,7 +33,6 @@ 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>
|
||||
@@ -60,21 +60,10 @@ 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) * BOARD_LANE_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 fixedItemSize = board.fixedScaling ? board.fixedItemSize : undefined;
|
||||
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>
|
||||
@@ -90,7 +79,7 @@ export const ClientBoard = () => {
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
fixedItemSize={fixedItemSize}
|
||||
scaling={getBoardScaling(board)}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
|
||||
@@ -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,
|
||||
@@ -39,7 +38,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 +48,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 +60,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 +88,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 +102,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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +1,53 @@
|
||||
.scalingSettings,
|
||||
.gutterSettings {
|
||||
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: 12rem;
|
||||
min-width: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.scalingSettings {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 62em) {
|
||||
.layoutPreview {
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.gutterPreview {
|
||||
|
||||
@@ -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,8 +23,8 @@ 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 { 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";
|
||||
@@ -31,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";
|
||||
|
||||
@@ -39,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");
|
||||
@@ -59,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);
|
||||
@@ -107,54 +109,13 @@ 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;
|
||||
const activeLayoutId =
|
||||
displayedLayouts.find(({ layout }) => layout.id === selectedLayoutId)?.layout.id ?? baseLayout?.id;
|
||||
|
||||
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>
|
||||
<ScalingSettings form={form} />
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={2} maw="52rem">
|
||||
@@ -179,144 +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")}
|
||||
description={tLayout("field.columnCount.description")}
|
||||
>
|
||||
<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,10 +1,12 @@
|
||||
"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";
|
||||
@@ -21,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";
|
||||
@@ -30,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({
|
||||
@@ -99,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),
|
||||
@@ -125,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]);
|
||||
@@ -170,6 +202,7 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
form.clearErrors();
|
||||
form.setValues(initialValuesRef.current);
|
||||
form.resetDirty();
|
||||
updateBoard((previous) => ({
|
||||
@@ -182,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) => {
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
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,
|
||||
@@ -359,6 +360,9 @@ const BoardSettingsForm = ({
|
||||
allowDecimal={false}
|
||||
suffix=" px"
|
||||
{...form.getInputProps("fixedItemSize")}
|
||||
onBlur={() =>
|
||||
form.setFieldValue("fixedItemSize", normalizeFixedItemSize(form.getValues().fixedItemSize))
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"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);
|
||||
let canvasTop = 16;
|
||||
if (settings.fixedScaling) canvasTop += 32 * metrics.scale;
|
||||
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) {
|
||||
|
||||
@@ -15,6 +15,24 @@ 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:
|
||||
|
||||
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@
|
||||
}
|
||||
|
||||
.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(--app-shell-header-offset, 0px) - var(--app-shell-padding, 1rem) -
|
||||
var(--app-shell-footer-offset, 0px) - var(--app-shell-padding, 1rem)
|
||||
100dvh - var(--board-canvas-shell-offset) - var(--board-canvas-shell-padding)
|
||||
);
|
||||
|
||||
height: var(--board-canvas-fixed-viewport-height);
|
||||
@@ -21,42 +22,6 @@
|
||||
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;
|
||||
@@ -87,42 +52,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,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { BOARD_GRID_ITEM_INSET, LOGICAL_GRID_PITCH } from "./constants";
|
||||
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);
|
||||
@@ -14,32 +19,11 @@ 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;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
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;
|
||||
|
||||
return canvasScale < 1 ? 1 / canvasScale : 1;
|
||||
};
|
||||
|
||||
interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
fixedItemSize?: number;
|
||||
scaling: BoardScaling;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -52,11 +36,11 @@ export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
initialLogicalHeight,
|
||||
initialAvailableWidth,
|
||||
fixedItemSize,
|
||||
scaling,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useElementSize<HTMLDivElement>();
|
||||
const { ref: viewportRef, width: availableWidth, height: viewportHeight } = useCanvasViewportSize();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
@@ -66,29 +50,27 @@ export const ScaledBoardCanvas = ({
|
||||
|
||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||
const isFixedScaling = fixedItemSize !== undefined;
|
||||
const scale = useMemo(() => {
|
||||
if (fixedItemSize !== undefined) return calculateFixedBoardCanvasScale(fixedItemSize);
|
||||
|
||||
return calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth);
|
||||
}, [fixedItemSize, 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;
|
||||
const shouldCenterCanvas = isFixedScaling && visualWidth <= resolvedAvailableWidth;
|
||||
|
||||
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={isFixedScaling ? "fixed" : "responsive"}
|
||||
data-canvas-scaling={scaling.mode}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-centered={shouldCenterCanvas ? "true" : undefined}
|
||||
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}
|
||||
@@ -106,7 +88,7 @@ export const ScaledBoardCanvas = ({
|
||||
>
|
||||
<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,
|
||||
|
||||
@@ -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([100, 150, 200, 400])("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(100);
|
||||
expect(normalizeFixedItemSize(700)).toBe(400);
|
||||
expect(normalizeFixedItemSize(200.7)).toBe(201);
|
||||
});
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -19,11 +19,8 @@ import {
|
||||
getReadonlyCanvasAttributes,
|
||||
normalizeGridPlacement,
|
||||
} from "~/components/board/layout";
|
||||
import {
|
||||
calculateBoardUiScale,
|
||||
useBoardCanvasScale,
|
||||
useBoardCanvasViewportHeight,
|
||||
} 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";
|
||||
|
||||
@@ -5432,13 +5432,13 @@
|
||||
},
|
||||
"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."
|
||||
"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} x {size} px"
|
||||
"previewValue": "{size} × {size} px"
|
||||
},
|
||||
"columnCount": {
|
||||
"label": "Column count"
|
||||
@@ -5465,6 +5465,9 @@
|
||||
"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."
|
||||
@@ -5472,7 +5475,11 @@
|
||||
"layout": {
|
||||
"title": "Layout",
|
||||
"scaling": {
|
||||
"title": "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",
|
||||
@@ -5493,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",
|
||||
@@ -5520,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"
|
||||
|
||||
Reference in new issue
Block a user