fix(boards): preview container contents
This commit is contained in:
5 files changed
+134
-24
No files matched your search
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user