refactor: simplify mobile board layouts
This commit is contained in:
1 parent
7bbca3bd40
commit
02fe99d5aa
30 files changed
+75
-1430
No files matched your search
@@ -7,7 +7,7 @@ export const getFirstEmptyPosition = (
|
||||
size: { width: number; height: number } = { width: 1, height: 1 },
|
||||
) => {
|
||||
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
|
||||
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
|
||||
const isOccupied = elements.some(
|
||||
(element) =>
|
||||
element.yOffset < yOffset + size.height &&
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveAndResizeItemInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -10,8 +11,10 @@ export interface MoveAndResizeItemInput {
|
||||
}
|
||||
|
||||
export const moveAndResizeItemCallback =
|
||||
({ itemId, layoutId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
({ itemId, ...layoutInput }: MoveAndResizeItemInput) =>
|
||||
(previous: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) =>
|
||||
@@ -20,7 +23,7 @@ export const moveAndResizeItemCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== layoutId
|
||||
layout.layoutId !== currentLayout
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export interface MoveItemToSectionInput {
|
||||
itemId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -11,8 +12,10 @@ export interface MoveItemToSectionInput {
|
||||
}
|
||||
|
||||
export const moveItemToSectionCallback =
|
||||
({ itemId, layoutId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
({ itemId, ...layoutInput }: MoveItemToSectionInput) =>
|
||||
(board: Board): Board => {
|
||||
const currentLayout = getCurrentLayout(board);
|
||||
|
||||
return {
|
||||
...board,
|
||||
items: board.items.map((item) =>
|
||||
@@ -21,7 +24,7 @@ export const moveItemToSectionCallback =
|
||||
: {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) =>
|
||||
layout.layoutId !== layoutId
|
||||
layout.layoutId !== currentLayout
|
||||
? layout
|
||||
: {
|
||||
...layout,
|
||||
|
||||
@@ -106,10 +106,6 @@ describe("get first empty position", () => {
|
||||
|
||||
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||
});
|
||||
|
||||
test("does not place an item wider than the layout", () => {
|
||||
expect(getFirstEmptyPosition([], 1, undefined, { width: 2, height: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { moveAndResizeItemCallback } from "../move-and-resize-item";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -21,7 +25,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -39,6 +43,8 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
xOffset: 20,
|
||||
yOffset: 30,
|
||||
@@ -50,7 +56,7 @@ describe("moveItemToSectionCallback should move item in section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveAndResizeItemCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as boardContext from "@homarr/boards/context";
|
||||
|
||||
import { moveItemToSectionCallback } from "../move-item-to-section";
|
||||
import { BoardMockBuilder } from "./mocks/board-mock";
|
||||
@@ -9,6 +11,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -22,7 +26,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemB).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(3);
|
||||
@@ -40,6 +44,8 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
// Arrange
|
||||
const itemToMove = "2";
|
||||
const layoutId = "1";
|
||||
const spy = vi.spyOn(boardContext, "getCurrentLayout");
|
||||
spy.mockReturnValue(layoutId);
|
||||
const newPosition = {
|
||||
sectionId: "3",
|
||||
xOffset: 20,
|
||||
@@ -52,7 +58,7 @@ describe("moveItemToSectionCallback should move item to section", () => {
|
||||
const board = new BoardMockBuilder().addItem(itemA).addItem(itemC).build();
|
||||
|
||||
// Act
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, layoutId, ...newPosition })(board);
|
||||
const updatedBoard = moveItemToSectionCallback({ itemId: itemToMove, ...newPosition })(board);
|
||||
|
||||
// Assert
|
||||
expect(updatedBoard.items).toHaveLength(2);
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
.container {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 12rem);
|
||||
gap: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.item > :global(.grid-stack-item-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties, RefObject } from "react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { ReadOnlyEditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homarr/gridstack";
|
||||
|
||||
import { BoardItemContent } from "../items/item-content";
|
||||
import { SectionProvider } from "../sections/section-context";
|
||||
import classes from "./mobile-board.module.css";
|
||||
import { createMobileBoardItems, getMobileRootSection, mobileColumnCount } from "./mobile-layout";
|
||||
|
||||
export const MobileBoard = () => {
|
||||
const board = useRequiredBoard();
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const rootSection = getMobileRootSection(board);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
|
||||
const gridstackRef = useRef<GridStackInstance | null>(null);
|
||||
|
||||
return (
|
||||
<ReadOnlyEditModeProvider>
|
||||
<SectionProvider
|
||||
value={{
|
||||
section: rootSection,
|
||||
items,
|
||||
innerSections: [],
|
||||
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
|
||||
}}
|
||||
>
|
||||
<Box ref={wrapperRef} className={classes.container}>
|
||||
<Box
|
||||
className={classes.grid}
|
||||
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
|
||||
data-mobile-board
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
className={classes.item}
|
||||
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
|
||||
data-mobile-board-item={item.id}
|
||||
>
|
||||
<BoardItemContent item={item} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</SectionProvider>
|
||||
</ReadOnlyEditModeProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CategorySectionMockBuilder } from "../items/actions/test/mocks/category-section-mock";
|
||||
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "../items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
|
||||
import { createMobileBoardItems, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout";
|
||||
|
||||
describe("createMobileBoardItems", () => {
|
||||
test("selects the first root section in visual order", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
board.sections.push(
|
||||
new EmptySectionMockBuilder({ id: "stored-first", xOffset: 0, yOffset: 1 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "visually-first", xOffset: 1, yOffset: 0 }).build(),
|
||||
new EmptySectionMockBuilder({ id: "visually-second", xOffset: 2, yOffset: 0 }).build(),
|
||||
);
|
||||
|
||||
expect(getMobileRootSection(board).id).toBe("visually-first");
|
||||
});
|
||||
|
||||
test("flattens categories and dynamic sections in visual order", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root", yOffset: 0 }).build();
|
||||
const category = new CategorySectionMockBuilder({ id: "category", yOffset: 1, collapsed: true }).build();
|
||||
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: rootSection.id, xOffset: 0, yOffset: 1 })
|
||||
.build();
|
||||
const nestedDynamicSection = new DynamicSectionMockBuilder({ id: "nested-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, parentSectionId: dynamicSection.id, xOffset: 0, yOffset: 1 })
|
||||
.build();
|
||||
|
||||
board.sections.push(rootSection, category, dynamicSection, nestedDynamicSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "second" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 1, yOffset: 0 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "first" })
|
||||
.addLayout({
|
||||
layoutId: desktopLayoutId,
|
||||
sectionId: rootSection.id,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 8,
|
||||
height: 6,
|
||||
})
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: dynamicSection.id })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-nested-dynamic" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: nestedDynamicSection.id })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "inside-collapsed-category" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: category.id })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createMobileBoardItems(board, desktopLayoutId);
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
"inside-dynamic",
|
||||
"inside-nested-dynamic",
|
||||
"inside-collapsed-category",
|
||||
]);
|
||||
expect(result[0]).toMatchObject({ width: 2, height: 3, xOffset: 0, yOffset: 0 });
|
||||
});
|
||||
|
||||
test("uses the desktop layout and keeps orphaned items", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const legacyLayoutId = board.layouts.at(0)?.id;
|
||||
if (!legacyLayoutId) throw new Error("Expected a legacy layout");
|
||||
const desktopLayoutId = "desktop";
|
||||
board.layouts.push({ id: desktopLayoutId, name: "Large", columnCount: 16, breakpoint: 1200 });
|
||||
board.sections.push(new EmptySectionMockBuilder({ id: "root" }).build());
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "first" })
|
||||
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 2 })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: "missing-section", yOffset: 0, width: 0, height: 0 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "second" })
|
||||
.addLayout({ layoutId: legacyLayoutId, sectionId: "root", yOffset: 0 })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: "root", yOffset: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
const result = createMobileBoardItems(board, desktopLayoutId);
|
||||
|
||||
expect(result.map((item) => item.id)).toEqual(["second", "first"]);
|
||||
expect(result[1]).toMatchObject({ width: 1, height: 1 });
|
||||
});
|
||||
|
||||
test("keeps useful desktop footprints within the two-by-three mobile limit", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
board.sections.push(rootSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "two-by-two" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 2, height: 2 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "one-by-one" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, xOffset: 2, width: 1, height: 1 })
|
||||
.build(),
|
||||
);
|
||||
|
||||
expect(createMobileBoardItems(board, desktopLayoutId)).toMatchObject([
|
||||
{ id: "two-by-two", width: 2, height: 2 },
|
||||
{ id: "one-by-one", width: 1, height: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMobileBoardPreviewItems", () => {
|
||||
test("returns no tiles for an empty board", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
|
||||
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]);
|
||||
});
|
||||
|
||||
test("uses the production mobile order, footprint, kind, app ID, and custom title", () => {
|
||||
const board = new BoardMockBuilder().build();
|
||||
const desktopLayoutId = board.layouts.at(0)?.id;
|
||||
if (!desktopLayoutId) throw new Error("Expected a desktop layout");
|
||||
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
|
||||
board.sections.push(rootSection);
|
||||
board.items.push(
|
||||
new ItemMockBuilder({ id: "wide", kind: "notebook" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 8, height: 6, yOffset: 1 })
|
||||
.build(),
|
||||
new ItemMockBuilder({ id: "first", kind: "app" })
|
||||
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 })
|
||||
.build(),
|
||||
);
|
||||
const appItem = board.items.find((item) => item.id === "first");
|
||||
if (!appItem || appItem.kind !== "app") throw new Error("Expected app board item");
|
||||
appItem.options.appId = "actual-app";
|
||||
const wideItem = board.items.find((item) => item.id === "wide");
|
||||
if (!wideItem) throw new Error("Expected wide board item");
|
||||
wideItem.advancedOptions.title = "Notes";
|
||||
|
||||
expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([
|
||||
{ id: "first", kind: "app", appId: "actual-app", width: 1, height: 1, title: null },
|
||||
{ id: "wide", kind: "notebook", appId: null, width: 2, height: 3, title: "Notes" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
export const mobileColumnCount = 2;
|
||||
const mobileMaxHeight = 3;
|
||||
|
||||
type PositionedElement = DynamicSectionItem | SectionItem;
|
||||
type PositionedLayout = {
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const comparePosition = (elementA: PositionedElement, elementB: PositionedElement) =>
|
||||
elementA.yOffset - elementB.yOffset || elementA.xOffset - elementB.xOffset;
|
||||
|
||||
const getMobileRootSections = (board: Board) =>
|
||||
board.sections
|
||||
.filter((section) => section.kind !== "dynamic")
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset || sectionA.xOffset - sectionB.xOffset);
|
||||
|
||||
export const getMobileRootSection = (board: Board) =>
|
||||
getMobileRootSections(board)[0] ?? {
|
||||
id: "mobile",
|
||||
kind: "empty" as const,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
};
|
||||
|
||||
const getLayout = <TLayout extends PositionedLayout>(
|
||||
layouts: TLayout[],
|
||||
desktopLayoutId: string,
|
||||
layoutPriority: Map<string, number>,
|
||||
) =>
|
||||
layouts.find((layout) => layout.layoutId === desktopLayoutId) ??
|
||||
layouts.toSorted(
|
||||
(layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1),
|
||||
)[0];
|
||||
|
||||
export const createMobileBoardItems = (board: Board, desktopLayoutId: string): SectionItem[] => {
|
||||
const rootSections = getMobileRootSections(board);
|
||||
const defaultSectionId = rootSections[0]?.id ?? "mobile";
|
||||
const layoutPriority = new Map(
|
||||
board.layouts
|
||||
.toSorted(
|
||||
(layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint || layoutA.columnCount - layoutB.columnCount,
|
||||
)
|
||||
.map((layout, index) => [layout.id, index]),
|
||||
);
|
||||
|
||||
const items = board.items.map(({ layouts, ...item }, index): SectionItem => {
|
||||
const layout =
|
||||
getLayout(layouts, desktopLayoutId, layoutPriority) ??
|
||||
({
|
||||
layoutId: desktopLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: 1,
|
||||
height: 1,
|
||||
sectionId: defaultSectionId,
|
||||
} satisfies ItemLayout);
|
||||
|
||||
return { ...item, ...layout, type: "item" };
|
||||
});
|
||||
|
||||
const dynamicSections = board.sections
|
||||
.filter((section) => section.kind === "dynamic")
|
||||
.map(({ layouts, ...section }): DynamicSectionItem | null => {
|
||||
const layout = getLayout(layouts, desktopLayoutId, layoutPriority);
|
||||
return layout ? { ...section, ...layout, type: "section" } : null;
|
||||
})
|
||||
.filter((section) => section !== null);
|
||||
|
||||
const elementsBySection = new Map<string, PositionedElement[]>();
|
||||
for (const element of [...items, ...dynamicSections]) {
|
||||
const parentSectionId = element.type === "item" ? element.sectionId : element.parentSectionId;
|
||||
const elements = elementsBySection.get(parentSectionId) ?? [];
|
||||
elements.push(element);
|
||||
elementsBySection.set(parentSectionId, elements);
|
||||
}
|
||||
|
||||
const result: SectionItem[] = [];
|
||||
const visitedItems = new Set<string>();
|
||||
const visitedSections = new Set<string>();
|
||||
|
||||
const appendSectionItems = (sectionId: string) => {
|
||||
if (visitedSections.has(sectionId)) return;
|
||||
visitedSections.add(sectionId);
|
||||
|
||||
for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) {
|
||||
if (element.type === "section") {
|
||||
appendSectionItems(element.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!visitedItems.has(element.id)) {
|
||||
visitedItems.add(element.id);
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
rootSections.forEach((section) => appendSectionItems(section.id));
|
||||
dynamicSections.toSorted(comparePosition).forEach((section) => appendSectionItems(section.id));
|
||||
items.toSorted(comparePosition).forEach((item) => {
|
||||
if (!visitedItems.has(item.id)) result.push(item);
|
||||
});
|
||||
|
||||
return result.map((item, index) => ({
|
||||
...item,
|
||||
xOffset: 0,
|
||||
yOffset: index,
|
||||
width: Math.max(1, Math.min(item.width, mobileColumnCount)),
|
||||
height: Math.max(1, Math.min(item.height, mobileMaxHeight)),
|
||||
}));
|
||||
};
|
||||
|
||||
export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: string) =>
|
||||
createMobileBoardItems(board, desktopLayoutId).map((item) => ({
|
||||
id: item.id,
|
||||
kind: item.kind,
|
||||
appId: item.kind === "app" && typeof item.options.appId === "string" ? item.options.appId : null,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
title: item.advancedOptions.title,
|
||||
}));
|
||||
@@ -1,44 +0,0 @@
|
||||
.panel {
|
||||
width: min(390px, calc(100vw - var(--mantine-spacing-xl)));
|
||||
max-height: min(70vh, 700px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: 42px;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appIcon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.panel,
|
||||
.item {
|
||||
scroll-behavior: auto;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ActionIcon, Affix, Button, Center, Group, Paper, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconDeviceMobile, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getDesktopLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { MaskedOrNormalImage } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import { useIsMobileBoard } from "../use-mobile-board";
|
||||
import classes from "./mobile-preview.module.css";
|
||||
import { createMobileBoardPreviewItems, mobileColumnCount } from "./mobile-layout";
|
||||
|
||||
export const MobileBoardPreview = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isMobileBoard = useIsMobileBoard();
|
||||
const { enableAutomaticMobileLayout } = useSettings();
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [enableAutomaticMobileLayout, isEditMode, isMobileBoard]);
|
||||
|
||||
if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null;
|
||||
|
||||
return (
|
||||
<Affix position={{ bottom: "var(--mantine-spacing-md)", left: "var(--mantine-spacing-md)" }} zIndex={200}>
|
||||
{isExpanded ? (
|
||||
<Paper className={classes.panel} radius="md" shadow="md" data-mobile-board-preview>
|
||||
<Group justify="space-between" wrap="nowrap" p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<IconDeviceMobile size={20} aria-hidden />
|
||||
<Text fw={600}>{t("board.mobilePreview.title")}</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
aria-label={t("board.mobilePreview.collapse")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<MobileBoardPreviewMap />
|
||||
</Paper>
|
||||
) : (
|
||||
<Button
|
||||
leftSection={<IconDeviceMobile size={20} />}
|
||||
onClick={() => setIsExpanded(true)}
|
||||
data-mobile-board-preview-toggle
|
||||
>
|
||||
{t("board.mobilePreview.action")}
|
||||
</Button>
|
||||
)}
|
||||
</Affix>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileBoardPreviewMap = () => {
|
||||
const board = useRequiredBoard();
|
||||
const desktopLayout = getDesktopLayout(board);
|
||||
const items = useMemo(() => createMobileBoardPreviewItems(board, desktopLayout.id), [board, desktopLayout.id]);
|
||||
const appIds = useMemo(
|
||||
() => [...new Set(items.flatMap((item) => (item.appId === null ? [] : [item.appId])))],
|
||||
[items],
|
||||
);
|
||||
const { data: apps = [] } = clientApi.app.byIds.useQuery(appIds, { enabled: appIds.length > 0 });
|
||||
const appsById = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize className={classes.viewport} mah="calc(min(70vh, 700px) - 52px)" p="sm">
|
||||
{items.length === 0 ? (
|
||||
<Center mih={160}>
|
||||
<Stack gap={4} align="center">
|
||||
<IconDeviceMobile size={28} color="var(--mantine-color-dimmed)" aria-hidden />
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("board.mobilePreview.empty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<div
|
||||
className={classes.grid}
|
||||
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
|
||||
data-mobile-board-preview-grid
|
||||
>
|
||||
{items.map((item) => {
|
||||
const WidgetIcon = widgetCatalogIcons[item.kind];
|
||||
const app = item.appId === null ? undefined : appsById.get(item.appId);
|
||||
const label = app?.name ?? (item.title?.trim() || t(`widget.${item.kind}.name`));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={classes.item}
|
||||
style={{ gridColumn: `span ${item.width}`, gridRow: `span ${item.height}` }}
|
||||
data-mobile-board-preview-item={item.id}
|
||||
>
|
||||
<Group className={classes.itemContent} gap={6} wrap="nowrap" px="xs">
|
||||
<ThemeIcon variant="light" size="sm" data-mobile-board-preview-app-icon={app?.id}>
|
||||
{app ? (
|
||||
<MaskedOrNormalImage imageUrl={app.iconUrl} hasColor={false} alt="" className={classes.appIcon} />
|
||||
) : (
|
||||
<WidgetIcon size={14} aria-hidden />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={500} truncate title={label}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { getCurrentLayout } from "@homarr/boards/context";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
innerSectionId: string;
|
||||
layoutId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
@@ -12,7 +12,6 @@ interface MoveAndResizeInnerSection {
|
||||
}
|
||||
interface MoveInnerSectionToSection {
|
||||
innerSectionId: string;
|
||||
layoutId: string;
|
||||
sectionId: string;
|
||||
xOffset: number;
|
||||
yOffset: number;
|
||||
@@ -24,7 +23,7 @@ export const useSectionActions = () => {
|
||||
const { updateBoard } = useUpdateBoard();
|
||||
|
||||
const moveAndResizeInnerSection = useCallback(
|
||||
({ innerSectionId, layoutId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
|
||||
updateBoard((previous) => ({
|
||||
...previous,
|
||||
sections: previous.sections.map((section) => {
|
||||
@@ -32,10 +31,12 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== layoutId) return layout;
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
@@ -49,7 +50,7 @@ export const useSectionActions = () => {
|
||||
);
|
||||
|
||||
const moveInnerSectionToSection = useCallback(
|
||||
({ innerSectionId, layoutId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
|
||||
updateBoard((previous) => {
|
||||
return {
|
||||
...previous,
|
||||
@@ -58,10 +59,12 @@ export const useSectionActions = () => {
|
||||
if (section.id !== innerSectionId) return section;
|
||||
if (section.kind !== "container") return section;
|
||||
|
||||
const currentLayout = getCurrentLayout(previous);
|
||||
|
||||
return {
|
||||
...section,
|
||||
layouts: section.layouts.map((layout) => {
|
||||
if (layout.layoutId !== layoutId) return layout;
|
||||
if (layout.layoutId !== currentLayout) return layout;
|
||||
return {
|
||||
...layout,
|
||||
...positionProps,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
import { useSettings } from "@homarr/settings";
|
||||
|
||||
export const mobileBoardMediaQuery = "(max-width: 48em)";
|
||||
|
||||
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
|
||||
|
||||
export const MobileBoardViewportProvider = ({
|
||||
initialIsMobile,
|
||||
children,
|
||||
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
|
||||
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
|
||||
);
|
||||
|
||||
export const useIsMobileBoard = () => {
|
||||
const initialIsMobile = useContext(InitialMobileBoardContext);
|
||||
const { enableAutomaticMobileLayout } = useSettings();
|
||||
const isMobileViewport = useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
|
||||
|
||||
return enableAutomaticMobileLayout && isMobileViewport;
|
||||
};
|
||||
Reference in new issue
Block a user