From dec04da806d800a99ad04e5be524871f22e0c5f0 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 8 Aug 2026 13:09:20 +0200 Subject: [PATCH] fix: refine mobile board preview --- .../board/mobile/mobile-layout.spec.ts | 11 +++++--- .../components/board/mobile/mobile-layout.ts | 1 + .../board/mobile/mobile-preview.module.css | 5 ++++ .../board/mobile/mobile-preview.tsx | 21 +++++++++++--- e2e/mobile-board.spec.ts | 28 ++++++++++++++----- 5 files changed, 51 insertions(+), 15 deletions(-) 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 4c7b7779c..8569acc16 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -125,7 +125,7 @@ describe("createMobileBoardPreviewItems", () => { expect(createMobileBoardPreviewItems(board, desktopLayoutId)).toEqual([]); }); - test("uses the production mobile order, footprint, kind, and custom title", () => { + 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"); @@ -135,17 +135,20 @@ describe("createMobileBoardPreviewItems", () => { 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" }) + 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: "clock", width: 1, height: 1, title: null }, - { id: "wide", kind: "notebook", width: 2, height: 3, title: "Notes" }, + { 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" }, ]); }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index c467ff702..fc3e1079d 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -120,6 +120,7 @@ export const createMobileBoardPreviewItems = (board: Board, desktopLayoutId: str 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, diff --git a/apps/nextjs/src/components/board/mobile/mobile-preview.module.css b/apps/nextjs/src/components/board/mobile/mobile-preview.module.css index d60bc97e5..c7ee974d5 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-preview.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-preview.module.css @@ -30,6 +30,11 @@ min-width: 0; } +.appIcon { + width: 14px; + height: 14px; +} + @media (prefers-reduced-motion: reduce) { .panel, .item { diff --git a/apps/nextjs/src/components/board/mobile/mobile-preview.tsx b/apps/nextjs/src/components/board/mobile/mobile-preview.tsx index 4441bc256..76c5bcb78 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-preview.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-preview.tsx @@ -5,10 +5,12 @@ 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"; @@ -31,7 +33,7 @@ export const MobileBoardPreview = () => { if (!isEditMode || isMobileBoard || !enableAutomaticMobileLayout) return null; return ( - + {isExpanded ? ( @@ -67,6 +69,12 @@ 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 ( @@ -88,7 +96,8 @@ const MobileBoardPreviewMap = () => { > {items.map((item) => { const WidgetIcon = widgetCatalogIcons[item.kind]; - const label = item.title?.trim() || t(`widget.${item.kind}.name`); + const app = item.appId === null ? undefined : appsById.get(item.appId); + const label = app?.name ?? (item.title?.trim() || t(`widget.${item.kind}.name`)); return (
{ data-mobile-board-preview-item={item.id} > - - + + {app ? ( + + ) : ( + + )} {label} diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts index eb3075cee..a0c850449 100644 --- a/e2e/mobile-board.spec.ts +++ b/e2e/mobile-board.spec.ts @@ -25,7 +25,8 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { const sectionId = createId(); const emptySectionId = createId(); const notebookId = createId(); - const clockId = createId(); + const appItemId = createId(); + const appId = createId(); await db.insert(sqliteSchema.boards).values({ id: boardId, @@ -65,6 +66,11 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { yOffset: 1, }, ]); + await db.insert(sqliteSchema.apps).values({ + id: appId, + name: "Media Server", + iconUrl: "/favicon.ico", + }); await db.insert(sqliteSchema.items).values([ { id: notebookId, @@ -74,10 +80,10 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { advancedOptions: stringify({}), }, { - id: clockId, + id: appItemId, boardId, - kind: "clock", - options: stringify({}), + kind: "app", + options: stringify({ appId }), advancedOptions: stringify({}), }, ]); @@ -92,7 +98,7 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { height: 2, }, { - itemId: clockId, + itemId: appItemId, layoutId: desktopLayoutId, sectionId, xOffset: 2, @@ -110,7 +116,7 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { height: 4, }, { - itemId: clockId, + itemId: appItemId, layoutId: mobileLayoutId, sectionId, xOffset: 0, @@ -127,6 +133,8 @@ const seedBoardAsync = async (db: SqliteDatabase, userId: string) => { completedBoardTour: true, }) .where(eq(sqliteSchema.users.id, userId)); + + return { appId }; }; describe("Automatic mobile board", () => { @@ -155,7 +163,7 @@ describe("Automatic mobile board", () => { 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); + const { appId } = await seedBoardAsync(db, userId); await db.insert(sqliteSchema.serverSettings).values({ settingKey: "board", value: stringify({ @@ -191,9 +199,15 @@ describe("Automatic mobile board", () => { 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(); + expect(await previewToggle.evaluate((element) => element.getBoundingClientRect().left)).toBeLessThan(32); await previewToggle.click(); await expect(loginPage.locator("[data-mobile-board-preview]")).toBeVisible(); await expect(loginPage.locator("[data-mobile-board-preview-item]")).toHaveCount(2); + await expect(loginPage.getByText("Media Server", { exact: true })).toBeVisible(); + await expect(loginPage.locator(`[data-mobile-board-preview-app-icon="${appId}"] img`)).toHaveAttribute( + "src", + "/favicon.ico", + ); await loginPage .locator("header button") .filter({ has: loginPage.locator("svg.tabler-icon-plus") })