diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index 282bde4fe..7dfa65cc2 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -1,8 +1,9 @@ .grid { + container-type: inline-size; display: grid; grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); grid-auto-flow: row; - grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem); + grid-auto-rows: min(calc((100cqw - 42px) / var(--mobile-column-count)), 12rem); gap: 10px; padding-bottom: 10px; } diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index e6c415682..4c7b7779c 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -5,7 +5,7 @@ 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, getMobileRootSection } from "./mobile-layout"; +import { createMobileBoardItems, createMobileBoardPreviewItems, getMobileRootSection } from "./mobile-layout"; describe("createMobileBoardItems", () => { test("selects the first root section in visual order", () => { @@ -115,3 +115,37 @@ describe("createMobileBoardItems", () => { ]); }); }); + +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, 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: "clock" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: rootSection.id, width: 1, height: 1, yOffset: 0 }) + .build(), + ); + 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: "clock", width: 1, height: 1, title: null }, + { id: "wide", kind: "notebook", width: 2, height: 3, title: "Notes" }, + ]); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index ab8bd4d65..c467ff702 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -115,3 +115,12 @@ export const createMobileBoardItems = (board: Board, desktopLayoutId: string): S 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, + width: item.width, + height: item.height, + title: item.advancedOptions.title, + })); diff --git a/apps/nextjs/src/components/board/mobile/mobile-preview.module.css b/apps/nextjs/src/components/board/mobile/mobile-preview.module.css new file mode 100644 index 000000000..d60bc97e5 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-preview.module.css @@ -0,0 +1,39 @@ +.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; +} + +@media (prefers-reduced-motion: reduce) { + .panel, + .item { + scroll-behavior: auto; + transition: none; + } +} diff --git a/apps/nextjs/src/components/board/mobile/mobile-preview.tsx b/apps/nextjs/src/components/board/mobile/mobile-preview.tsx new file mode 100644 index 000000000..4441bc256 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-preview.tsx @@ -0,0 +1,115 @@ +"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 { 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 { 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 ( + + {isExpanded ? ( + + + + + {t("board.mobilePreview.title")} + + setIsExpanded(false)} + aria-label={t("board.mobilePreview.collapse")} + > + + + + + + ) : ( + } + onClick={() => setIsExpanded(true)} + data-mobile-board-preview-toggle + > + {t("board.mobilePreview.action")} + + )} + + ); +}; + +const MobileBoardPreviewMap = () => { + const board = useRequiredBoard(); + const desktopLayout = getDesktopLayout(board); + const items = useMemo(() => createMobileBoardPreviewItems(board, desktopLayout.id), [board, desktopLayout.id]); + const t = useI18n(); + + return ( + + {items.length === 0 ? ( + + + + + {t("board.mobilePreview.empty")} + + + + ) : ( + + {items.map((item) => { + const WidgetIcon = widgetCatalogIcons[item.kind]; + const label = item.title?.trim() || t(`widget.${item.kind}.name`); + + return ( + + + + + + + {label} + + + + ); + })} + + )} + + ); +}; diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index 8e684b45a..7d4b2fdba 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -1,7 +1,6 @@ -import type { Page } from "@playwright/test"; import { chromium, devices, expect } from "@playwright/test"; import { createId } from "@paralleldrive/cuid2"; -import { stringify } from "superjson"; +import { parse, stringify } from "superjson"; import { describe, test } from "vitest"; import { eq } from "drizzle-orm"; @@ -19,23 +18,12 @@ const credentials = { const boardName = "mobile-e2e"; -const setAutomaticMobileLayoutAsync = async (page: Page, enabled: boolean) => { - await page.goto(new URL("/manage/settings", page.url()).toString()); - const toggle = page.getByRole("switch", { name: "Use automatic mobile layout" }); - await expect(toggle).toBeVisible(); - - if ((await toggle.isChecked()) !== enabled) { - await toggle.setChecked(enabled); - await toggle.locator("xpath=ancestor::form").getByRole("button", { name: "Save", exact: true }).click(); - await expect(page.getByText("Settings saved successfully")).toBeVisible(); - } -}; - const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { const boardId = createId(); const desktopLayoutId = createId(); const mobileLayoutId = createId(); const sectionId = createId(); + const emptySectionId = createId(); const notebookId = createId(); const clockId = createId(); @@ -60,14 +48,23 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { breakpoint: 0, }, ]); - await db.insert(sqliteSchema.sections).values({ - id: sectionId, - boardId, - kind: "category", - name: "Dashboard", - xOffset: 0, - yOffset: 0, - }); + await db.insert(sqliteSchema.sections).values([ + { + id: sectionId, + boardId, + kind: "category", + name: "Dashboard", + xOffset: 0, + yOffset: 0, + }, + { + id: emptySectionId, + boardId, + kind: "empty", + xOffset: 0, + yOffset: 1, + }, + ]); await db.insert(sqliteSchema.items).values([ { id: notebookId, @@ -133,10 +130,42 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { }; describe("Automatic mobile board", () => { + test("enables automatic mobile layout for a fresh database", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const container = await createHomarrContainer({ + mounts: { + "/appdata": localMountPath, + }, + }).start(); + + try { + const boardSettings = await db.query.serverSettings.findFirst({ + where: eq(sqliteSchema.serverSettings.settingKey, "board"), + }); + if (!boardSettings) throw new Error("Expected board server settings to be seeded"); + + expect(parse<{ enableAutomaticMobileLayout: boolean }>(boardSettings.value).enableAutomaticMobileLayout).toBe( + true, + ); + } finally { + await container.stop(); + } + }, 90_000); + test("squeezes unchanged widgets into a read-only two-column grid", async () => { const { db, localMountPath } = await createSqliteDbFileAsync(); const { userId } = await seedAdminUserAsync(db, credentials); await seedBoardAsync(db, userId); + await db.insert(sqliteSchema.serverSettings).values({ + settingKey: "board", + value: stringify({ + homeBoardId: null, + mobileHomeBoardId: null, + enableAutomaticMobileLayout: true, + enableStatusByDefault: true, + forceDisableStatus: false, + }), + }); const container = await createHomarrContainer({ environment: { @@ -157,7 +186,26 @@ describe("Automatic mobile board", () => { await loginPage.locator("#password").fill(credentials.password); await loginPage.locator("button[type='submit']").click(); await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); - await setAutomaticMobileLayoutAsync(loginPage, true); + await loginPage.goto(`${baseUrl}/boards/${boardName}`); + await expect(loginPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0); + await loginPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click(); + const previewToggle = loginPage.getByRole("button", { name: "Mobile preview", exact: true }); + await expect(previewToggle).toBeVisible(); + await previewToggle.click(); + await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible(); + await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2); + await loginPage + .locator("header button") + .filter({ has: loginPage.locator("svg.tabler-icon-plus") }) + .click(); + await loginPage.getByRole("menuitem", { name: "New item", exact: true }).click(); + await loginPage.getByPlaceholder("Filter items...").fill("Weather"); + await loginPage.getByPlaceholder("Filter items...").press("Enter"); + await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(3); + await loginPage.getByRole("button", { name: "Cancel", exact: true }).click(); + await loginPage.getByRole("button", { name: "Collapse mobile preview", exact: true }).click(); + await expect(loginPage.locator("[data-mobile-board-preview]")).toHaveCount(0); + await expect(previewToggle).toBeVisible(); const storageState = await loginContext.storageState(); const mobileContext = await browser.newContext({ @@ -173,6 +221,7 @@ describe("Automatic mobile board", () => { const mobileBoard = mobilePage.locator("[data-mobile-board]"); const items = mobilePage.locator("[data-mobile-board-item]"); await expect(mobileBoard).toBeVisible({ timeout: 15_000 }); + await expect(mobilePage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0); await expect(items).toHaveCount(2); await expect(mobilePage.locator(".grid-stack-item")).toHaveCount(0); await expect(mobilePage.getByText("Normal notebook content")).toBeVisible(); @@ -204,11 +253,43 @@ describe("Automatic mobile board", () => { expect(metrics.firstToSecondHeightRatio).toBeGreaterThan(1.8); expect(metrics.horizontalOverflow).toBeLessThanOrEqual(0); await mobileContext.close(); + } finally { + await loginContext.close(); + await browser.close(); + await container.stop(); + } + }, 180_000); - await setAutomaticMobileLayoutAsync(loginPage, false); + test("keeps preview and generated mobile grid disabled in legacy mode", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId } = await seedAdminUserAsync(db, credentials); + await seedBoardAsync(db, userId); + + const container = await createHomarrContainer({ + environment: { + AUTH_PROVIDERS: "credentials", + }, + mounts: { + "/appdata": localMountPath, + }, + }).start(); + const baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const desktopContext = await browser.newContext(); + + try { + const desktopPage = await desktopContext.newPage(); + await desktopPage.goto(`${baseUrl}/auth/login`); + await desktopPage.getByLabel("Username").fill(credentials.username); + await desktopPage.locator("#password").fill(credentials.password); + await desktopPage.locator("button[type='submit']").click(); + await desktopPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); + await desktopPage.goto(`${baseUrl}/boards/${boardName}`); + await desktopPage.getByRole("button", { name: "Toggle board edit mode", exact: true }).click(); + await expect(desktopPage.locator("[data-mobile-board-preview-toggle]")).toHaveCount(0); const legacyContext = await browser.newContext({ - storageState: await loginContext.storageState(), + storageState: await desktopContext.storageState(), viewport: { width: 390, height: 844 }, userAgent: devices["iPhone 13"].userAgent, hasTouch: true, @@ -221,7 +302,7 @@ describe("Automatic mobile board", () => { await expect(legacyPage.getByRole("button", { name: "Toggle board edit mode", exact: true })).toBeVisible(); await legacyContext.close(); } finally { - await loginContext.close(); + await desktopContext.close(); await browser.close(); await container.stop(); } diff --git a/packages/api/src/router/serverSettings.ts b/packages/api/src/router/serverSettings.ts index 2a603c757..5456c7a22 100644 --- a/packages/api/src/router/serverSettings.ts +++ b/packages/api/src/router/serverSettings.ts @@ -55,7 +55,7 @@ export const serverSettingsRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null", + "Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableAutomaticMobileLayout, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards returned by the public-board listing tool or API, using each board's id field, or be null", }, }) .input(boardServerSettingsUpdateSchema) diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts index 5346026c0..802fd6559 100644 --- a/packages/db/migrations/seed.spec.ts +++ b/packages/db/migrations/seed.spec.ts @@ -72,6 +72,61 @@ describe("automatic mobile layout upgrade default", () => { }); }); + test("persists the enabled default when a fresh instance is seeded repeatedly", async () => { + const db = createDb(); + + await seedServerSettingsAsync(db); + await seedServerSettingsAsync(db); + + await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board); + }); + + test("backfills the enabled default for canonical layouts", async () => { + const db = createDb(); + const boardId = createId(); + await db.insert(boards).values({ id: boardId, name: "dashboard", isPublic: false }); + await db.insert(layouts).values({ + id: createId(), + boardId, + name: "Base", + columnCount: 12, + breakpoint: 0, + }); + await db.insert(serverSettings).values({ + settingKey: "board", + value: stringify({ + homeBoardId: null, + mobileHomeBoardId: null, + enableStatusByDefault: true, + forceDisableStatus: false, + }), + }); + + await seedServerSettingsAsync(db); + await seedServerSettingsAsync(db); + + await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual(defaultServerSettings.board); + }); + + test("does not override an explicit disabled choice", async () => { + const db = createDb(); + await db.insert(serverSettings).values({ + settingKey: "board", + value: stringify({ + ...defaultServerSettings.board, + enableAutomaticMobileLayout: false, + }), + }); + + await seedServerSettingsAsync(db); + await seedServerSettingsAsync(db); + + await expect(getServerSettingByKeyAsync(db, "board")).resolves.toStrictEqual({ + ...defaultServerSettings.board, + enableAutomaticMobileLayout: false, + }); + }); + test("backfills legacy responsive instances once without overriding an explicit choice", async () => { const db = createDb(); const boardId = createId(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index bddeeca2c..d2ac1d9b8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5011,6 +5011,12 @@ } }, "board": { + "mobilePreview": { + "title": "Mobile preview", + "action": "Mobile preview", + "collapse": "Collapse mobile preview", + "empty": "Add items to the board to preview the automatic mobile layout." + }, "action": { "addContent": "Add board content", "settings": "Board settings",