diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css new file mode 100644 index 000000000..37c67e66e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css @@ -0,0 +1,39 @@ +.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; +} + +.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(0.65rem, 1.7vw, 1.15rem); + min-height: 2.5rem; +} + +.tile { + 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.3rem; +} + +.dynamicTile { + 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; +} diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx new file mode 100644 index 000000000..e0b21a408 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { IconLayoutGrid } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { useI18n } from "@homarr/translation/client"; +import { MaskedOrNormalImage } from "@homarr/ui"; +import { widgetCatalogIcons } from "@homarr/widgets/catalog"; + +import type { Board } from "../../_types"; +import type { BoardLayout } from "../../_layout-utils"; +import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils"; +import classes from "./_layout-preview.module.css"; + +interface Props { + board: Board; + layout: BoardLayout; + layouts: BoardLayout[]; + sourceLayout: Pick; + apps: RouterOutputs["app"]["byIds"]; +} + +export const LayoutPreview = ({ board, layout, layouts, sourceLayout, apps }: Props) => { + 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 !== "dynamic") + .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); + + return ( + + + + {representativeWidth}px + + + {layout.columnCount} {t("board.setting.section.layout.preview.columns")} + + + + {elements.length === 0 ? ( + + {t("board.setting.section.layout.preview.empty")} + + ) : ( + + {rootSections.map((section) => { + const sectionElements = elements.filter((element) => element.sectionId === section.id); + if (sectionElements.length === 0) return null; + + return ( + + {section.kind === "category" && ( + + {section.name} + + )} + + {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 dynamicSection = + element.type === "section" + ? board.sections.find((candidate) => candidate.id === element.id) + : undefined; + const dynamicSectionLabel = + dynamicSection?.kind === "dynamic" && dynamicSection.options.title + ? dynamicSection.options.title + : t("board.setting.section.layout.preview.dynamicSection"); + const label = app?.name ?? (item ? t(`widget.${item.kind}.name`) : dynamicSectionLabel); + const WidgetIcon = item ? widgetCatalogIcons[item.kind] : IconLayoutGrid; + + return ( + + + + {app ? ( + + ) : ( + + )} + + + {label} + + + + ); + })} + + + ); + })} + + )} + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts new file mode 100644 index 000000000..c0add2c85 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "vitest"; + +import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock"; +import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock"; +import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock"; +import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock"; +import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils"; + +const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const }; +const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const }; +const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const }; + +describe("layout representative widths", () => { + test("uses a phone width, activation midpoint, and desktop minimum", () => { + const layouts = [mobile, custom, base]; + expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390); + expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624); + expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280); + }); +}); + +describe("projectBoardLayout", () => { + test("uses production ordering and sizing for items and dynamic sections", () => { + const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); + const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" }) + .addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 }) + .build(); + const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" }) + .addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 }) + .build(); + const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" }) + .addLayout({ layoutId: base.id, sectionId: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 }) + .build(); + const board = new BoardMockBuilder() + .addLayout(mobile) + .addSections([rootSection, dynamicSection]) + .addItems([rootItem, nestedItem]) + .build(); + + const projected = projectBoardLayout(board, base, mobile); + + expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({ + type: "item", + width: 3, + height: 2, + xOffset: 0, + yOffset: 0, + sectionId: rootSection.id, + }); + expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({ + type: "section", + width: 3, + xOffset: 0, + yOffset: 2, + sectionId: rootSection.id, + }); + expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({ + type: "item", + width: 3, + sectionId: dynamicSection.id, + }); + }); + + test("ignores missing saved positions and supports empty boards", () => { + const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); + const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build(); + const board = new BoardMockBuilder() + .addLayout(mobile) + .addSection(rootSection) + .addItem(itemWithoutBasePosition) + .build(); + + expect(projectBoardLayout(board, base, mobile)).toEqual([]); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts new file mode 100644 index 000000000..24dc6bd34 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts @@ -0,0 +1,74 @@ +import { generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; + +import type { Board } from "./_types"; + +export type BoardLayout = Board["layouts"][number]; + +export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => { + if (layout.role === "mobile") return 390; + if (layout.role === "base") return Math.max(1280, layout.breakpoint); + + const nextLayout = allLayouts + .filter((candidate) => candidate.breakpoint > layout.breakpoint) + .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint) + .at(0); + + return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint); +}; + +export const projectBoardLayout = ( + board: Board, + sourceLayout: Pick, + targetLayout: Pick, +) => { + const elements = getElementsForLayout(board, sourceLayout.id); + + return board.sections + .filter((section) => section.kind !== "dynamic") + .flatMap( + (section) => + generateResponsiveGridFor({ + items: elements, + previousWidth: sourceLayout.columnCount, + width: targetLayout.columnCount, + sectionId: section.id, + }).items, + ); +}; + +const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [ + ...board.items.flatMap((item) => { + const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId); + return layout + ? [ + { + id: item.id, + type: "item" as const, + height: layout.height, + width: layout.width, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.sectionId, + }, + ] + : []; + }), + ...board.sections.flatMap((section) => { + if (section.kind !== "dynamic") return []; + const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); + return layout?.parentSectionId + ? [ + { + id: section.id, + type: "section" as const, + height: layout.height, + width: layout.width, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.parentSectionId, + }, + ] + : []; + }), +]; diff --git a/apps/nextjs/src/app/[locale]/boards/_providers.tsx b/apps/nextjs/src/app/[locale]/boards/_providers.tsx new file mode 100644 index 000000000..cdf917deb --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/_providers.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { useSearchParams } from "next/navigation"; +import { useMediaQuery } from "@mantine/hooks"; + +import { BoardProvider } from "@homarr/boards/context"; +import { EditModeProvider } from "@homarr/boards/edit-mode"; + +import type { Board } from "./_types"; + +interface Props { + initialBoard: Board; + canModify: boolean; +} + +export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren) => { + const searchParams = useSearchParams(); + const isDesktop = useMediaQuery("(min-width: 64em)"); + const requestedLayoutId = searchParams.get("layout"); + const isRequestedLayoutValid = initialBoard.layouts.some((layout) => layout.id === requestedLayoutId); + const isLayoutEditRequest = canModify && searchParams.get("edit") === "true" && isRequestedLayoutValid; + const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null; + + return ( + + {children} + + ); +}; diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts index 56f870336..9dfc14d3e 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/board-mock.ts @@ -47,7 +47,8 @@ export class BoardMockBuilder { columnCount: 12, leftGutterColumnCount: 0, rightGutterColumnCount: 0, - breakpoint: 0, + breakpoint: 768, + role: "base", }, ], ...board, diff --git a/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts b/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts index 5fba9fc1a..edc8bcab2 100644 --- a/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts +++ b/apps/nextjs/src/components/board/items/actions/test/mocks/layout-mock.ts @@ -12,7 +12,8 @@ export class LayoutMockBuilder { columnCount: 12, leftGutterColumnCount: 0, rightGutterColumnCount: 0, - breakpoint: 0, + breakpoint: 768, + role: "base", ...layout, } satisfies Board["layouts"][0]; } diff --git a/packages/api/src/router/board/grid-algorithm.ts b/packages/api/src/router/board/grid-algorithm.ts deleted file mode 100644 index 78d7ab7fc..000000000 --- a/packages/api/src/router/board/grid-algorithm.ts +++ /dev/null @@ -1,186 +0,0 @@ -export interface GridAlgorithmItem { - id: string; - type: "item" | "section"; - width: number; - height: number; - xOffset: number; - yOffset: number; - sectionId: string; -} - -interface GridAlgorithmInput { - items: GridAlgorithmItem[]; - width: number; - previousWidth: number; - sectionId: string; -} - -interface GridAlgorithmOutput { - height: number; - items: GridAlgorithmItem[]; -} - -export const generateResponsiveGridFor = ({ - items, - previousWidth, - width, - sectionId, -}: GridAlgorithmInput): GridAlgorithmOutput => { - const itemsOfCurrentSection = items - .filter((item) => item.sectionId === sectionId) - .toSorted((itemA, itemB) => - itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset, - ); - const normalizedItems = normalizeItems(itemsOfCurrentSection, width); - - if (itemsOfCurrentSection.length === 0) { - return { - height: 0, - items: [], - }; - } - - const newItems: GridAlgorithmItem[] = []; - - // A container must remain tall enough for its recursively reflowed contents. - const containerHeightMap = new Map(); - const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section"); - for (const container of containersOfCurrentSection) { - const result = generateResponsiveGridFor({ - items, - previousWidth: container.previousWidth, - width: container.width, - sectionId: container.id, - }); - newItems.push(...result.items); - containerHeightMap.set(container.id, result.height); - } - - // Return same positions for items in the current section - if (width >= previousWidth) { - return { - height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)), - items: newItems.concat(normalizedItems), - }; - } - - const occupied2d: boolean[][] = []; - - for (const item of normalizedItems) { - const itemWithHeight = { - ...item, - height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height, - }; - const position = nextFreeSpot(occupied2d, itemWithHeight, width); - if (!position) throw new Error("No free spot available"); - - addItemToOccupied(occupied2d, itemWithHeight, position, width); - newItems.push({ - ...itemWithHeight, - xOffset: position.x, - yOffset: position.y, - }); - } - - return { - height: occupied2d.length, - items: newItems, - }; -}; - -/** - * Reduces the width of the items to fit the new column count. - * @param items items to normalize - * @param columnCount new column count - */ -const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => { - return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) })); -}; - -/** - * Adds the item to the occupied spots. - * @param occupied2d array of occupied spots - * @param item item to place - * @param position position to place the item - */ -const addItemToOccupied = ( - occupied2d: boolean[][], - item: GridAlgorithmItem, - position: { x: number; y: number }, - columnCount: number, -) => { - for (let yOffset = 0; yOffset < item.height; yOffset++) { - let row = occupied2d[position.y + yOffset]; - if (!row) { - addRow(occupied2d, columnCount); - // After adding it, it must exist - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - row = occupied2d[position.y + yOffset]!; - } - - for (let xOffset = 0; xOffset < item.width; xOffset++) { - row[position.x + xOffset] = true; - } - } -}; - -/** - * Adds a new row to the grid. - * @param occupied2d array of occupied spots - * @param columnCount column count of section - */ -const addRow = (occupied2d: boolean[][], columnCount: number) => { - occupied2d.push(new Array(columnCount).fill(false)); -}; - -/** - * Searches for the next free spot in the grid. - * @param occupied2d array of occupied spots - * @param item item to place - * @param columnCount column count of section - * @returns the position of the next free spot or null if no spot is available - */ -const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => { - for (let offsetY = 0; offsetY < 99999; offsetY++) { - for (let offsetX = 0; offsetX < columnCount; offsetX++) { - if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) { - return { x: offsetX, y: offsetY }; - } - } - } - - return null; -}; - -/** - * Check if the item fits into the grid horizontally. - * @param columnCount available width - * @param item item to place - * @param offsetX current x position - * @returns true if the item fits horizontally - */ -const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => { - return offsetX + item.width <= columnCount; -}; - -/** - * Check if the spot is free. - * @param occupied2d array of occupied spots - * @param item item to place - * @param position position to check - * @returns true if the spot is free - */ -const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => { - for (let yOffset = 0; yOffset < item.height; yOffset++) { - const row = occupied2d[position.y + yOffset]; - if (!row) return true; // Empty row is free - - for (let xOffset = 0; xOffset < item.width; xOffset++) { - if (row[position.x + xOffset]) { - return false; - } - } - } - - return true; -}; diff --git a/packages/api/src/router/board/test/grid-algorithm.spec.ts b/packages/api/src/router/board/test/grid-algorithm.spec.ts index 13c66759c..722c52724 100644 --- a/packages/api/src/router/board/test/grid-algorithm.spec.ts +++ b/packages/api/src/router/board/test/grid-algorithm.spec.ts @@ -1,8 +1,8 @@ import { createId } from "@paralleldrive/cuid2"; import { describe, expect, test } from "vitest"; -import type { GridAlgorithmItem } from "../grid-algorithm"; -import { generateResponsiveGridFor } from "../grid-algorithm"; +import { generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; const ROOT_SECTION_ID = "section"; diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 4f43a9533..c195c9815 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({ } const section = board.sections.find((boardSection) => boardSection.kind === "empty"); - const layout = board.layouts[0]; - - if (section && layout) { + if (section && board.layouts.length > 0) { const existingItems = await db.query.items.findMany({ where: eq(items.boardId, board.id), }); @@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({ await placeAllWidgetsAsync( db, - { + board.layouts.map((layout) => ({ boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount, - }, + })), allIntegrations, allApps, ); diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx index 9b813b032..58288ce2a 100644 --- a/packages/boards/src/edit-mode.tsx +++ b/packages/boards/src/edit-mode.tsx @@ -1,13 +1,18 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect } from "react"; import { useDisclosure } from "@mantine/hooks"; const EditModeContext = createContext | null>(null); -export const EditModeProvider = ({ children }: PropsWithChildren) => { - const editModeDisclosure = useDisclosure(false); +export const EditModeProvider = ({ children, initialOpen = false }: PropsWithChildren<{ initialOpen?: boolean }>) => { + const editModeDisclosure = useDisclosure(initialOpen); + const [, { open }] = editModeDisclosure; + + useEffect(() => { + if (initialOpen) open(); + }, [initialOpen, open]); return {children}; }; diff --git a/packages/common/src/grid-algorithm.ts b/packages/common/src/grid-algorithm.ts new file mode 100644 index 000000000..83d165d70 --- /dev/null +++ b/packages/common/src/grid-algorithm.ts @@ -0,0 +1,125 @@ +export interface GridAlgorithmItem { + id: string; + type: "item" | "section"; + width: number; + height: number; + xOffset: number; + yOffset: number; + sectionId: string; +} + +interface GridAlgorithmInput { + items: GridAlgorithmItem[]; + width: number; + previousWidth: number; + sectionId: string; +} + +interface GridAlgorithmOutput { + height: number; + items: GridAlgorithmItem[]; +} + +export const generateResponsiveGridFor = ({ + items, + previousWidth, + width, + sectionId, +}: GridAlgorithmInput): GridAlgorithmOutput => + generateResponsiveGridForSection({ items, previousWidth, width, sectionId }, new Set()); + +const generateResponsiveGridForSection = ( + { items, previousWidth, width, sectionId }: GridAlgorithmInput, + ancestorSectionIds: ReadonlySet, +): GridAlgorithmOutput => { + if (ancestorSectionIds.has(sectionId)) return { height: 0, items: [] }; + + const nextAncestorSectionIds = new Set(ancestorSectionIds).add(sectionId); + const itemsOfCurrentSection = items + .filter((item) => item.sectionId === sectionId) + .toSorted((itemA, itemB) => + itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset, + ); + const normalizedItems = normalizeItems(itemsOfCurrentSection, width); + + if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] }; + + const newItems: GridAlgorithmItem[] = []; + const dynamicSectionHeightMap = new Map(); + for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) { + const result = generateResponsiveGridForSection( + { + items, + previousWidth: dynamicSection.previousWidth, + width: dynamicSection.width, + sectionId: dynamicSection.id, + }, + nextAncestorSectionIds, + ); + newItems.push(...result.items); + dynamicSectionHeightMap.set(dynamicSection.id, result.height); + } + + if (width >= previousWidth) { + return { + height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)), + items: newItems.concat(normalizedItems), + }; + } + + const occupied2d: boolean[][] = []; + for (const item of normalizedItems) { + const itemWithHeight = { + ...item, + height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height, + }; + const position = nextFreeSpot(occupied2d, itemWithHeight, width); + if (!position) throw new Error("No free spot available"); + + addItemToOccupied(occupied2d, itemWithHeight, position, width); + newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y }); + } + + return { height: occupied2d.length, items: newItems }; +}; + +const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => + items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) })); + +const addItemToOccupied = ( + occupied2d: boolean[][], + item: GridAlgorithmItem, + position: { x: number; y: number }, + columnCount: number, +) => { + for (let yOffset = 0; yOffset < item.height; yOffset++) { + let row = occupied2d[position.y + yOffset]; + if (!row) { + row = Array.from({ length: columnCount }, () => false); + occupied2d.push(row); + } + for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true; + } +}; + +const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => { + for (let offsetY = 0; offsetY < 99999; offsetY++) { + for (let offsetX = 0; offsetX < columnCount; offsetX++) { + if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) { + return { x: offsetX, y: offsetY }; + } + } + } + return null; +}; + +const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => { + for (let yOffset = 0; yOffset < item.height; yOffset++) { + const row = occupied2d[position.y + yOffset]; + if (!row) return true; + for (let xOffset = 0; xOffset < item.width; xOffset++) { + if (row[position.x + xOffset]) return false; + } + } + return true; +}; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5f82ea6a3..f67a771ad 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -11,4 +11,5 @@ export * from "./number"; export * from "./error"; export * from "./theme"; export * from "./function"; +export * from "./grid-algorithm"; export * from "./id"; diff --git a/packages/db/migrations/mysql/0044_add_layout_role.sql b/packages/db/migrations/mysql/0044_add_layout_role.sql new file mode 100644 index 000000000..c2a0a7a87 --- /dev/null +++ b/packages/db/migrations/mysql/0044_add_layout_role.sql @@ -0,0 +1 @@ +ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL; diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json index 57f1c8ac5..82f35c2e3 100644 --- a/packages/db/migrations/mysql/meta/_journal.json +++ b/packages/db/migrations/mysql/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1786212549933, "tag": "0045_add_layout_gutters", "breakpoints": true + }, + { + "idx": 46, + "version": "5", + "when": 1786215199760, + "tag": "0046_add_layout_role", + "breakpoints": true } ] } diff --git a/packages/db/migrations/postgresql/0010_add_layout_role.sql b/packages/db/migrations/postgresql/0010_add_layout_role.sql new file mode 100644 index 000000000..62f73fe1a --- /dev/null +++ b/packages/db/migrations/postgresql/0010_add_layout_role.sql @@ -0,0 +1 @@ +ALTER TABLE "layout" ADD COLUMN "role" varchar(16) DEFAULT 'custom' NOT NULL; diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json index c3dacd7f3..36189d05b 100644 --- a/packages/db/migrations/postgresql/meta/_journal.json +++ b/packages/db/migrations/postgresql/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1786212457549, "tag": "0011_add_layout_gutters", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1786215201393, + "tag": "0012_add_layout_role", + "breakpoints": true } ] } diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts new file mode 100644 index 000000000..375849e29 --- /dev/null +++ b/packages/db/migrations/seed.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "vitest"; + +import { createId } from "@homarr/common"; +import { eq } from "@homarr/db"; +import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import { emptySuperJSON } from "@homarr/definitions"; + +import { seedProtectedBoardLayoutsAsync } from "./seed"; + +describe("protected board layout migration", () => { + test("turns a single saved layout into Base and generates Mobile", async () => { + const db = createDb(); + const boardId = createId(); + const baseLayoutId = createId(); + const sectionId = createId(); + const itemId = createId(); + await db.insert(boards).values({ id: boardId, name: "single" }); + await db.insert(layouts).values({ + id: baseLayoutId, + boardId, + name: "Wide", + columnCount: 12, + breakpoint: 0, + }); + await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 }); + await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON }); + await db.insert(itemLayouts).values({ + itemId, + sectionId, + layoutId: baseLayoutId, + width: 5, + height: 2, + xOffset: 6, + yOffset: 1, + }); + + await seedProtectedBoardLayoutsAsync(db); + await seedProtectedBoardLayoutsAsync(db); + + const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); + expect(savedLayouts).toHaveLength(2); + expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({ + id: baseLayoutId, + name: "Wide", + columnCount: 12, + breakpoint: 768, + }); + const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile"); + expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 }); + const mobileItemPosition = await db.query.itemLayouts.findFirst({ + where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""), + }); + expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 }); + }); + + test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "responsive" }); + await db.insert(layouts).values([ + { id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 }, + { id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 }, + { id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 }, + { id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 }, + ]); + + await seedProtectedBoardLayoutsAsync(db); + await seedProtectedBoardLayoutsAsync(db); + + const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); + expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([ + { id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" }, + { id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" }, + { id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" }, + { id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" }, + ]); + }); + + test("creates both protected layouts for a board with no layouts", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "empty" }); + + await seedProtectedBoardLayoutsAsync(db); + await seedProtectedBoardLayoutsAsync(db); + + const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) }); + expect(savedLayouts).toHaveLength(2); + expect(savedLayouts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }), + expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }), + ]), + ); + }); +}); diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 412c09e1e..3042ca522 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -1,6 +1,6 @@ import SuperJSON from "superjson"; -import { createId, objectKeys } from "@homarr/common"; +import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common"; import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import { createDocumentationLink, @@ -11,11 +11,12 @@ import { getIntegrationName, integrationDefs, integrationKinds, + normalizeBoardLayoutRoles, } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings"; -import type { Database } from ".."; +import type { Database, InferInsertModel } from ".."; import { eq, inArray } from ".."; import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries"; import { @@ -39,6 +40,7 @@ import { onboarding, searchEngines, sections, + sectionLayouts, users, } from "../schema"; import type { Integration } from "../schema"; @@ -65,6 +67,215 @@ export const seedDataAsync = async (db: Database) => { if (isTruthyEnv(process.env.DEMO_MODE)) { await seedDemoUserAsync(db); } + + await seedProtectedBoardLayoutsAsync(db); +}; + +export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: string) => { + const dbBoards = await db.query.boards.findMany({ + where: boardId ? eq(boards.id, boardId) : undefined, + with: { + layouts: true, + items: { with: { layouts: true } }, + sections: { with: { layouts: true } }, + }, + }); + + for (const board of dbBoards) { + if (board.layouts.length === 0) { + await db.insert(layouts).values([ + { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile", + boardId: board.id, + }, + { + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 768, + role: "base", + boardId: board.id, + }, + ]); + continue; + } + + if (board.layouts.length === 1) { + const [baseLayout] = board.layouts; + if (!baseLayout) continue; + + const mobileLayout = { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile" as const, + boardId: board.id, + }; + + await db.update(layouts).set({ role: "base", breakpoint: 768 }).where(eq(layouts.id, baseLayout.id)); + await db.insert(layouts).values(mobileLayout); + await insertMissingProjectedPositionsAsync( + db, + board, + { ...baseLayout, role: "base", breakpoint: 768 }, + mobileLayout, + ); + continue; + } + + const normalizedLayouts = normalizeBoardLayoutRoles(board.layouts); + const alreadyNormalized = normalizedLayouts.every((layout) => { + const previousLayout = board.layouts.find((candidate) => candidate.id === layout.id); + return layout.breakpoint === previousLayout?.breakpoint && previousLayout.role === layout.role; + }); + + if (!alreadyNormalized) { + for (const layout of normalizedLayouts) { + await db + .update(layouts) + .set({ breakpoint: layout.breakpoint, role: layout.role }) + .where(eq(layouts.id, layout.id)); + } + } + + const mobileLayout = normalizedLayouts[0]; + const baseLayout = normalizedLayouts.at(-1); + if (mobileLayout && baseLayout) { + await insertMissingProjectedPositionsAsync( + db, + board, + { ...baseLayout, role: "base" }, + { ...mobileLayout, role: "mobile" }, + ); + } + } +}; + +interface BoardWithLayoutPositions { + items: Array<{ + id: string; + layouts: Array<{ + layoutId: string; + sectionId: string; + width: number; + height: number; + xOffset: number; + yOffset: number; + }>; + }>; + sections: Array<{ + id: string; + kind: string; + layouts: Array<{ + layoutId: string; + parentSectionId: string | null; + width: number; + height: number; + xOffset: number; + yOffset: number; + }>; + }>; +} + +const insertMissingProjectedPositionsAsync = async ( + db: Database, + board: BoardWithLayoutPositions, + sourceLayout: InferInsertModel, + targetLayout: InferInsertModel, +) => { + const elements = [ + ...board.items.flatMap((item) => { + const layout = item.layouts.find((itemLayout) => itemLayout.layoutId === sourceLayout.id); + return layout + ? [ + { + id: item.id, + type: "item" as const, + width: layout.width, + height: layout.height, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.sectionId, + }, + ] + : []; + }), + ...board.sections.flatMap((section) => { + if (section.kind !== "dynamic") return []; + const layout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === sourceLayout.id); + return layout?.parentSectionId + ? [ + { + id: section.id, + type: "section" as const, + width: layout.width, + height: layout.height, + xOffset: layout.xOffset, + yOffset: layout.yOffset, + sectionId: layout.parentSectionId, + }, + ] + : []; + }), + ]; + + const projectedElements = board.sections + .filter((section) => section.kind !== "dynamic") + .flatMap( + (section) => + generateResponsiveGridFor({ + items: elements, + previousWidth: sourceLayout.columnCount, + width: targetLayout.columnCount, + sectionId: section.id, + }).items, + ); + + const existingItemIds = new Set( + board.items + .filter((item) => item.layouts.some((layout) => layout.layoutId === targetLayout.id)) + .map((item) => item.id), + ); + const itemPositions = projectedElements + .filter((element) => element.type === "item" && !existingItemIds.has(element.id)) + .map( + (element): InferInsertModel => ({ + itemId: element.id, + layoutId: targetLayout.id, + sectionId: element.sectionId, + width: element.width, + height: element.height, + xOffset: element.xOffset, + yOffset: element.yOffset, + }), + ); + + const existingSectionIds = new Set( + board.sections + .filter((section) => section.layouts.some((layout) => layout.layoutId === targetLayout.id)) + .map((section) => section.id), + ); + const sectionPositions = projectedElements + .filter((element) => element.type === "section" && !existingSectionIds.has(element.id)) + .map( + (element): InferInsertModel => ({ + sectionId: element.id, + layoutId: targetLayout.id, + parentSectionId: element.sectionId, + width: element.width, + height: element.height, + xOffset: element.xOffset, + yOffset: element.yOffset, + }), + ); + + if (itemPositions.length > 0) await db.insert(itemLayouts).values(itemPositions); + if (sectionPositions.length > 0) await db.insert(sectionLayouts).values(sectionPositions); }; const seedEveryoneGroupAsync = async (db: Database) => { @@ -271,13 +482,24 @@ const seedDefaultBoardAsync = async (db: Database) => { yOffset: 0, boardId, }); - await db.insert(layouts).values({ - id: createId(), - name: "Base", - columnCount: 10, - breakpoint: 0, - boardId, - }); + await db.insert(layouts).values([ + { + id: createId(), + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile", + boardId, + }, + { + id: createId(), + name: "Base", + columnCount: 10, + breakpoint: 768, + role: "base", + boardId, + }, + ]); const everyoneGroupRow = await db.query.groups.findFirst({ where: eq(groups.name, everyoneGroup), @@ -508,7 +730,8 @@ const seedDemoUserAsync = async (db: Database) => { id: layoutId, name: "Base", columnCount: 12, - breakpoint: 0, + breakpoint: 768, + role: "base", boardId, }); @@ -598,7 +821,7 @@ const seedBoardWidgetsAsync = async (db: Database) => { if (!board) return; const section = board.sections.find((sec) => sec.kind === "empty"); - const layout = board.layouts[0]; + const layout = board.layouts.find((candidate) => candidate.role === "base") ?? board.layouts[0]; if (!section || !layout) return; const allIntegrations = await db.query.integrations.findMany(); diff --git a/packages/db/migrations/sqlite/0042_add_layout_role.sql b/packages/db/migrations/sqlite/0042_add_layout_role.sql new file mode 100644 index 000000000..d0a800e0d --- /dev/null +++ b/packages/db/migrations/sqlite/0042_add_layout_role.sql @@ -0,0 +1 @@ +ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL; diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json index ed48c0b57..394339b64 100644 --- a/packages/db/migrations/sqlite/meta/_journal.json +++ b/packages/db/migrations/sqlite/meta/_journal.json @@ -309,6 +309,13 @@ "when": 1786212459480, "tag": "0043_add_layout_gutters", "breakpoints": true + }, + { + "idx": 44, + "version": "6", + "when": 1786215203092, + "tag": "0044_add_layout_role", + "breakpoints": true } ] } diff --git a/packages/db/queries/widget-placement.spec.ts b/packages/db/queries/widget-placement.spec.ts new file mode 100644 index 000000000..b8acdc3b3 --- /dev/null +++ b/packages/db/queries/widget-placement.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "vitest"; + +import { createId } from "@homarr/common"; +import { boards, layouts, sections } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { placeAllWidgetsAsync } from "./widget-placement"; + +describe("placeAllWidgetsAsync", () => { + test("creates independent positions for every target layout", async () => { + const db = createDb(); + const boardId = createId(); + const sectionId = createId(); + const mobileLayoutId = createId(); + const baseLayoutId = createId(); + await db.insert(boards).values({ id: boardId, name: "dashboard" }); + await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId }); + await db.insert(layouts).values([ + { + id: mobileLayoutId, + name: "Mobile", + role: "mobile", + columnCount: 3, + breakpoint: 0, + boardId, + }, + { + id: baseLayoutId, + name: "Base", + role: "base", + columnCount: 10, + breakpoint: 768, + boardId, + }, + ]); + + await placeAllWidgetsAsync( + db, + [ + { boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 }, + { boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 }, + ], + [], + [{ id: createId(), name: "Homarr" }], + ); + + const placedItems = await db.query.items.findMany({ with: { layouts: true } }); + expect(placedItems.length).toBeGreaterThan(0); + expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true); + expect( + placedItems.every((item) => + item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3), + ), + ).toBe(true); + expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true); + }); +}); diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts index 1d0b529f9..c3a246b2f 100644 --- a/packages/db/queries/widget-placement.ts +++ b/packages/db/queries/widget-placement.ts @@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget { const placeWidgetAsync = async ( db: Database, - ctx: PlacementContext, + contexts: PlacementContext[], kind: WidgetKind, linkedIntegrationIds: string[], options?: string, size?: { width: number; height: number }, ) => { - const itemWidth = size?.width ?? 2; - const itemHeight = size?.height ?? 2; + const boardId = contexts.at(0)?.boardId; + if (!boardId) return; - if (ctx.xOffset + itemWidth > ctx.columnCount) { - ctx.xOffset = 0; - ctx.yOffset += ctx.rowMaxHeight; - ctx.rowMaxHeight = 0; - } + const desiredItemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; const itemId = createId(); await db.insert(items).values({ id: itemId, - boardId: ctx.boardId, + boardId, kind, options: options ?? emptySuperJSON, advancedOptions: emptySuperJSON, }); - await db.insert(itemLayouts).values({ - itemId, - sectionId: ctx.sectionId, - layoutId: ctx.layoutId, - xOffset: ctx.xOffset, - yOffset: ctx.yOffset, - width: itemWidth, - height: itemHeight, - }); + await db.insert(itemLayouts).values( + contexts.map((ctx) => { + const itemWidth = Math.min(ctx.columnCount, desiredItemWidth); + if (ctx.xOffset + itemWidth > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const placement = { + itemId, + sectionId: ctx.sectionId, + layoutId: ctx.layoutId, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width: itemWidth, + height: itemHeight, + }; + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; + return placement; + }), + ); for (const integrationId of linkedIntegrationIds) { await db.insert(integrationItems).values({ itemId, integrationId }); } - - ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); - ctx.xOffset += itemWidth; }; interface App { @@ -79,16 +87,17 @@ interface App { export const placeAllWidgetsAsync = async ( db: Database, - target: BoardTarget, + target: BoardTarget | BoardTarget[], allIntegrations: Integration[], allApps: App[], ) => { - const ctx: PlacementContext = { - ...target, + const targets = Array.isArray(target) ? target : [target]; + const contexts: PlacementContext[] = targets.map((currentTarget) => ({ + ...currentTarget, xOffset: 0, yOffset: 0, rowMaxHeight: 0, - }; + })); const placedWidgets = new Set(); @@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async ( const options = config?.options ? superjson.stringify(config.options) : undefined; await placeWidgetAsync( db, - ctx, + contexts, widgetKind, matchingIds, options, @@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async ( for (const app of allApps) { await placeWidgetAsync( db, - ctx, + contexts, "app", [], superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), @@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async ( options = { ...options, items: bookmarkAppIds }; } - await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, { + await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, { width: config.width, height: config.height, }); diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index ee891635c..1d9637a3b 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -34,6 +34,7 @@ import type { IntegrationKind, IntegrationPermission, IntegrationSecretKind, + LayoutRole, OnboardingStep, SearchEngineType, SectionKind, @@ -343,6 +344,7 @@ export const layouts = mysqlTable("layout", { leftGutterColumnCount: tinyint().notNull().default(0), rightGutterColumnCount: tinyint().notNull().default(0), breakpoint: smallint().notNull().default(0), + role: varchar({ length: 16 }).$type().notNull().default("custom"), }); export const itemLayouts = mysqlTable( diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts index 559fbd9eb..622686935 100644 --- a/packages/db/schema/postgresql.ts +++ b/packages/db/schema/postgresql.ts @@ -33,6 +33,7 @@ import type { IntegrationKind, IntegrationPermission, IntegrationSecretKind, + LayoutRole, OnboardingStep, SearchEngineType, SectionKind, @@ -342,6 +343,7 @@ export const layouts = pgTable("layout", { leftGutterColumnCount: smallint().notNull().default(0), rightGutterColumnCount: smallint().notNull().default(0), breakpoint: smallint().notNull().default(0), + role: varchar({ length: 16 }).$type().notNull().default("custom"), }); export const itemLayouts = pgTable( diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 59c5e1197..54f5f92bf 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -21,6 +21,7 @@ import type { IntegrationKind, IntegrationPermission, IntegrationSecretKind, + LayoutRole, OnboardingStep, SearchEngineType, SectionKind, @@ -327,6 +328,7 @@ export const layouts = sqliteTable("layout", { leftGutterColumnCount: int().notNull().default(0), rightGutterColumnCount: int().notNull().default(0), breakpoint: int().notNull().default(0), + role: text().$type().notNull().default("custom"), }); export const itemLayouts = sqliteTable( diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts index 302008550..3bd5ffdd9 100644 --- a/packages/definitions/src/board.ts +++ b/packages/definitions/src/board.ts @@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r export const backgroundImageSizes = createDefinition(["cover", "contain"], { defaultValue: "cover", }); +export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" }); export type BackgroundImageAttachment = inferDefinitionType; export type BackgroundImageRepeat = inferDefinitionType; export type BackgroundImageSize = inferDefinitionType; +export type LayoutRole = inferDefinitionType; + +const MAX_LAYOUT_BREAKPOINT = 32767; +const compareBoardLayouts = ( + layoutA: { id: string; breakpoint: number; columnCount: number }, + layoutB: { id: string; breakpoint: number; columnCount: number }, +) => + layoutA.breakpoint - layoutB.breakpoint || + layoutA.columnCount - layoutB.columnCount || + layoutA.id.localeCompare(layoutB.id); + +export const normalizeBoardLayoutRoles = < + TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole }, +>( + sourceLayouts: TLayout[], +): Array => { + const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile"); + const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); + const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; + const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; + const orderedLayouts = + mobileLayout && baseLayout + ? [ + mobileLayout, + ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), + baseLayout, + ] + : sourceLayouts.toSorted(compareBoardLayouts); + + let previousBreakpoint = -1; + return orderedLayouts.map((layout, index) => { + const remainingLayouts = orderedLayouts.length - index - 1; + const breakpoint = + index === 0 + ? 0 + : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); + const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; + previousBreakpoint = breakpoint; + return { ...layout, breakpoint, role }; + }); +}; diff --git a/packages/old-import/src/import/collections/board-collection.ts b/packages/old-import/src/import/collections/board-collection.ts index bbf9b68dd..b7b116ac6 100644 --- a/packages/old-import/src/import/collections/board-collection.ts +++ b/packages/old-import/src/import/collections/board-collection.ts @@ -1,6 +1,7 @@ import { createId } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { createDbInsertCollectionForTransaction } from "@homarr/db/collection"; +import { normalizeBoardLayoutRoles } from "@homarr/definitions"; import type { BoardSize, OldmarrConfig } from "@homarr/old-schema"; import { boardSizes, getBoardSizeName } from "@homarr/old-schema"; @@ -88,14 +89,19 @@ export const createBoardInsertCollection = ( {} as Record, ); - const preparedLayouts = boardSizes.map((size) => ({ - id: layoutMapping[size], - boardId: mappedBoard.id, - columnCount: mapColumnCount(board.config.settings.customization.gridstack, size), - breakpoint: mapBreakpoint(size), - name: getBoardSizeName(size), - })); - insertCollection.layouts.push(...preparedLayouts); + const importedLayouts = normalizeBoardLayoutRoles( + boardSizes.map((size) => ({ + id: layoutMapping[size], + boardId: mappedBoard.id, + columnCount: mapColumnCount(board.config.settings.customization.gridstack, size), + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: mapBreakpoint(size), + name: getBoardSizeName(size), + })), + ); + + insertCollection.layouts.push(...importedLayouts); const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories }); @@ -113,7 +119,7 @@ export const createBoardInsertCollection = ( layoutMapping, mappedBoard.id, ); - insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, preparedLayouts)); + insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, importedLayouts)); preparedItems.forEach(({ layouts, ...item }) => { insertCollection.items.push(item); insertCollection.itemLayouts.push(...layouts); diff --git a/packages/old-import/src/prepare/prepare-items.ts b/packages/old-import/src/prepare/prepare-items.ts index 0f32d6b00..61e2b5793 100644 --- a/packages/old-import/src/prepare/prepare-items.ts +++ b/packages/old-import/src/prepare/prepare-items.ts @@ -1,9 +1,9 @@ +import { generateResponsiveGridFor } from "@homarr/common"; +import type { GridAlgorithmItem } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema"; import { boardSizes } from "@homarr/old-schema"; -import type { GridAlgorithmItem } from "../../../api/src/router/board/grid-algorithm"; -import { generateResponsiveGridFor } from "../../../api/src/router/board/grid-algorithm"; import { mapColumnCount } from "../mappers/map-column-count"; import { mapApp, mapWidget } from "../mappers/map-item"; diff --git a/packages/validation/src/board.spec.ts b/packages/validation/src/board.spec.ts index 8a6e8b37c..27b973e53 100644 --- a/packages/validation/src/board.spec.ts +++ b/packages/validation/src/board.spec.ts @@ -1,9 +1,57 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, test } from "vitest"; +import { responsiveBoardLayoutsSchema } from "./board"; import { containerSectionOptionsDefaults, sectionSchema } from "./shared"; +const mobile = { + id: "mobile", + name: "Mobile", + columnCount: 3, + breakpoint: 0, + role: "mobile" as const, +}; +const base = { + id: "base", + name: "Base", + columnCount: 12, + breakpoint: 768, + role: "base" as const, +}; + +describe("responsiveBoardLayoutsSchema", () => { + test("accepts protected layouts and custom layouts strictly between them", () => { + expect( + responsiveBoardLayoutsSchema.safeParse([ + mobile, + { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" }, + base, + ]).success, + ).toBe(true); + }); + + test.each([ + { name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] }, + { name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] }, + { name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] }, + { + name: "custom layout at Base breakpoint", + layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base], + }, + { + name: "duplicate layout ID", + layouts: [mobile, { ...base, id: mobile.id }], + }, + { + name: "mobile sidebar", + layouts: [{ ...mobile, leftGutterColumnCount: 1 }, base], + }, + ])("rejects $name", ({ layouts }) => { + expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false); + }); +}); + describe("section behavior validation", () => { - it("normalizes a container to the current behavior defaults", () => { + test("normalizes a container to the current behavior defaults", () => { const result = sectionSchema.parse({ id: "container", kind: "container", @@ -16,7 +64,7 @@ describe("section behavior validation", () => { }); }); - it("preserves configured container behavior", () => { + test("preserves configured container behavior", () => { const result = sectionSchema.parse({ id: "container", kind: "container", @@ -44,7 +92,7 @@ describe("section behavior validation", () => { }); }); - it("normalizes the legacy dynamic kind to a container", () => { + test("normalizes the legacy dynamic kind to a container", () => { expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({ id: "legacy", kind: "container", @@ -52,11 +100,11 @@ describe("section behavior validation", () => { }); }); - it("rejects the legacy category kind", () => { + test("rejects the legacy category kind", () => { expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow(); }); - it.each([ + test.each([ { xOffset: 0.5, yOffset: 0, width: 1, height: 1 }, { xOffset: -1, yOffset: 0, width: 1, height: 1 }, { xOffset: 0, yOffset: 0, width: 0, height: 1 },