diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index d68a94c51..261b0058c 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -20,7 +20,8 @@ You can create as many boards as you want. Boards can be restricted to user groups. A user may also have a "home board" which they will see at the root path (e.g. my-homarr.com or my-homarr.com/board). Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) or via the management pages. -The management page includes a compact structural preview of each board's Base layout so you can recognize it before opening it. +The management page includes a compact structural preview of each board's Base layout so you can recognize it before +opening it. Containers include their visible widgets and app icons instead of appearing empty. Names of boards must be unique because this will be used in the URL to load the desired board. diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css index 1b137ee7a..8d7e38dd3 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css @@ -76,6 +76,14 @@ background: color-mix(in srgb, var(--mantine-color-teal-light) 55%, var(--mantine-color-body)); } +.containerContents { + position: absolute; + inset: rem(2px); + display: grid; + gap: rem(1px); + overflow: hidden; +} + @media (max-width: $mantine-breakpoint-xs) { .canvas { min-height: 7rem; diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index ee0fac463..48ba6fc6b 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -42,6 +42,16 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro ); } + const renderItemIcon = (itemId: string) => { + const item = preview.items.find((candidate) => candidate.id === itemId); + const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : undefined; + return item?.iconUrl ? ( + + ) : WidgetIcon ? ( + + ) : null; + }; + return (
{laneElements.map((element) => { - const item = element.type === "item" ? preview.items.find((item) => item.id === element.id) : undefined; - const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : undefined; + const nestedItems = + element.type === "section" + ? elements.filter((candidate) => candidate.type === "item" && candidate.sectionId === element.id) + : []; return ( - {item?.iconUrl ? ( - - ) : WidgetIcon ? ( - - ) : null} + {element.type === "section" ? ( + + {nestedItems.map((nestedItem) => ( + + {renderItemIcon(nestedItem.id)} + + ))} + + ) : ( + renderItemIcon(element.id) + )} ); })} diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 2095dc608..21cdb9ef4 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -283,7 +283,7 @@ export const boardRouter = createTRPCRouter({ const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id)); const rootSectionIds = dbBoards.flatMap((board) => board.sections.map((section) => section.id)); - const [previewItemLayouts, previewSectionLayouts] = + const [rootPreviewItemLayouts, previewSectionLayouts] = previewLayoutIds.length > 0 && rootSectionIds.length > 0 ? await Promise.all([ ctx.db.query.itemLayouts.findMany({ @@ -339,6 +339,43 @@ export const boardRouter = createTRPCRouter({ ]) : [[], []]; + const previewContainerSectionIds = [ + ...new Set( + previewSectionLayouts + .filter((layout) => layout.section.kind === "container") + .map((layout) => layout.sectionId), + ), + ]; + const nestedPreviewItemLayouts = + previewLayoutIds.length > 0 && previewContainerSectionIds.length > 0 + ? await ctx.db.query.itemLayouts.findMany({ + columns: { + itemId: true, + layoutId: true, + sectionId: true, + xOffset: true, + yOffset: true, + width: true, + height: true, + }, + with: { + item: { + columns: { kind: true, options: true }, + }, + }, + where: and( + inArray(itemLayouts.layoutId, previewLayoutIds), + inArray(itemLayouts.sectionId, previewContainerSectionIds), + gte(itemLayouts.xOffset, 0), + gte(itemLayouts.yOffset, 0), + lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + gt(itemLayouts.width, 0), + gt(itemLayouts.height, 0), + ), + }) + : []; + const previewItemLayouts = [...rootPreviewItemLayouts, ...nestedPreviewItemLayouts]; + const appIdByItemId = new Map( previewItemLayouts.flatMap((layout) => { if (layout.item.kind !== "app") return []; @@ -389,19 +426,6 @@ export const boardRouter = createTRPCRouter({ return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount; }; - const itemPreview = previewLayout - ? previewItemLayouts - .filter((layout) => layout.layoutId === previewLayout.id && isInsideRootLane(layout)) - .map((layout) => { - const appId = appIdByItemId.get(layout.itemId); - return { - id: layout.itemId, - kind: layout.item.kind, - iconUrl: appId ? appIconUrlById.get(appId) : undefined, - layouts: [layout], - }; - }) - : []; const containerPreview = previewLayout ? previewSectionLayouts .filter( @@ -417,6 +441,34 @@ export const boardRouter = createTRPCRouter({ layouts: [layout], })) : []; + const visibleContainerSizeById = new Map( + containerPreview.map((section) => [section.id, section.layouts[0]] as const), + ); + const isInsideVisibleContainer = (layout: { sectionId: string; xOffset: number; yOffset: number }) => { + const containerLayout = visibleContainerSizeById.get(layout.sectionId); + return ( + containerLayout !== undefined && + layout.xOffset < containerLayout.width && + layout.yOffset < containerLayout.height + ); + }; + const itemPreview = previewLayout + ? previewItemLayouts + .filter( + (layout) => + layout.layoutId === previewLayout.id && + (isInsideRootLane(layout) || isInsideVisibleContainer(layout)), + ) + .map((layout) => { + const appId = appIdByItemId.get(layout.itemId); + return { + id: layout.itemId, + kind: layout.item.kind, + iconUrl: appId ? appIconUrlById.get(appId) : undefined, + layouts: [layout], + }; + }) + : []; return { ...board, diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 382b5f88a..d62eb9f31 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -350,6 +350,7 @@ describe("getManageOverview", () => { const mobileItemId = createId(); const offscreenItemId = createId(); const nestedItemId = createId(); + const nestedAppId = createId(); await db.insert(users).values({ id: defaultCreatorId }); await db.insert(boards).values([ @@ -400,11 +401,16 @@ describe("getManageOverview", () => { width: 6, height: 3, }); + await db.insert(apps).values({ + id: nestedAppId, + name: "Nested app", + iconUrl: "https://example.com/nested.svg", + }); await db.insert(items).values([ { id: itemId, boardId, kind: "clock" }, { id: mobileItemId, boardId, kind: "clock" }, { id: offscreenItemId, boardId, kind: "clock" }, - { id: nestedItemId, boardId, kind: "clock" }, + { id: nestedItemId, boardId, kind: "app", options: SuperJSON.stringify({ appId: nestedAppId }) }, ]); await db.insert(itemLayouts).values([ { @@ -461,6 +467,12 @@ describe("getManageOverview", () => { kind: "clock", layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }], }, + { + id: nestedItemId, + kind: "app", + iconUrl: "https://example.com/nested.svg", + layouts: [{ layoutId: baseLayoutId, sectionId: containerSectionId, width: 2, height: 1 }], + }, ], sections: [ { id: rootSectionId, kind: "empty" }, @@ -473,7 +485,7 @@ describe("getManageOverview", () => { }, }); expect(result[0]?.preview?.layouts).toHaveLength(1); - expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId]); + expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]); expect(result[0]?.preview?.sections).toHaveLength(2); });