fix(boards): preview container contents

This commit is contained in:
Thomas Camlong committed 2026-08-14 02:16:14 +02:00
1 parent 00e9dbe268
commit 4641839fa4
5 files changed
+134 -24

No files matched your search

+2 -1
View File
@@ -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.
@@ -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;
@@ -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 ? (
<Image className={classes.itemIcon} src={item.iconUrl} alt="" fit="contain" />
) : WidgetIcon ? (
<WidgetIcon className={classes.itemIcon} stroke={1.7} />
) : null;
};
return (
<Box className={classes.canvas} role="img" aria-label={label}>
<div
@@ -75,8 +85,10 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
}}
>
{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 (
<span
key={`${element.type}-${element.id}`}
@@ -87,11 +99,36 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
}}
>
{item?.iconUrl ? (
<Image className={classes.itemIcon} src={item.iconUrl} alt="" fit="contain" />
) : WidgetIcon ? (
<WidgetIcon className={classes.itemIcon} stroke={1.7} />
) : null}
{element.type === "section" ? (
<span
className={classes.containerContents}
style={{
gridTemplateColumns: `repeat(${element.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${element.height}, minmax(0, 1fr))`,
}}
>
{nestedItems.map((nestedItem) => (
<span
key={nestedItem.id}
className={classes.tile}
style={{
gridColumn: `${nestedItem.xOffset + 1} / span ${Math.min(
nestedItem.width,
element.width - nestedItem.xOffset,
)}`,
gridRow: `${nestedItem.yOffset + 1} / span ${Math.min(
nestedItem.height,
element.height - nestedItem.yOffset,
)}`,
}}
>
{renderItemIcon(nestedItem.id)}
</span>
))}
</span>
) : (
renderItemIcon(element.id)
)}
</span>
);
})}
+66 -14
View File
@@ -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,
+14 -2
View File
@@ -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);
});