Skip to content

Commit 612f2d7

Browse files
committed
fix: address board review and CI feedback
1 parent 92597dd commit 612f2d7

7 files changed

Lines changed: 196 additions & 95 deletions

File tree

apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import type { JSX, PropsWithChildren } from "react";
2+
import { headers } from "next/headers";
23
import { notFound, redirect } from "next/navigation";
34
import { AppShellMain } from "@mantine/core";
45
import { TRPCError } from "@trpc/server";
56

67
import { auth } from "@homarr/auth/next";
78
import { BoardProvider } from "@homarr/boards/context";
89
import { EditModeProvider } from "@homarr/boards/edit-mode";
10+
import { userAgent } from "@homarr/common/server";
911
import { createLogger } from "@homarr/core/infrastructure/logs";
1012

13+
import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board";
1114
import { MainHeader } from "~/components/layout/header";
1215
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
1316
import { ClientShell } from "~/components/layout/shell";
@@ -61,28 +64,31 @@ export const createBoardLayout = <TParams extends Params>({
6164

6265
throw error;
6366
});
64-
const colorScheme = await getCurrentColorSchemeAsync();
67+
const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]);
68+
const initialIsMobile = userAgent(new Headers(requestHeaders)).device.type === "mobile";
6569

6670
return (
67-
<BoardProvider initialBoard={initialBoard}>
68-
<BoardReadyProvider>
69-
<EditModeProvider>
70-
<BoardMantineProvider defaultColorScheme={colorScheme}>
71-
<CustomCss />
72-
<BoardTourWrapper hasSession={withTour && !!session}>
73-
<ClientShell hasNavigation={false}>
74-
<MainHeader
75-
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
76-
actions={headerActions}
77-
hasNavigation={false}
78-
/>
79-
<AppShellMain>{children}</AppShellMain>
80-
</ClientShell>
81-
</BoardTourWrapper>
82-
</BoardMantineProvider>
83-
</EditModeProvider>
84-
</BoardReadyProvider>
85-
</BoardProvider>
71+
<MobileBoardViewportProvider initialIsMobile={initialIsMobile}>
72+
<BoardProvider initialBoard={initialBoard}>
73+
<BoardReadyProvider>
74+
<EditModeProvider>
75+
<BoardMantineProvider defaultColorScheme={colorScheme}>
76+
<CustomCss />
77+
<BoardTourWrapper hasSession={withTour && !!session}>
78+
<ClientShell hasNavigation={false}>
79+
<MainHeader
80+
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
81+
actions={headerActions}
82+
hasNavigation={false}
83+
/>
84+
<AppShellMain>{children}</AppShellMain>
85+
</ClientShell>
86+
</BoardTourWrapper>
87+
</BoardMantineProvider>
88+
</EditModeProvider>
89+
</BoardReadyProvider>
90+
</BoardProvider>
91+
</MobileBoardViewportProvider>
8692
);
8793
};
8894

apps/nextjs/src/components/board/mobile/mobile-board.module.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.grid {
22
display: grid;
3-
grid-template-columns: repeat(2, minmax(0, 1fr));
3+
grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr));
44
grid-auto-flow: row;
55
grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem);
66
gap: 10px;

apps/nextjs/src/components/board/mobile/mobile-board.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import type { RefObject } from "react";
3+
import type { CSSProperties, RefObject } from "react";
44
import { useMemo, useRef } from "react";
55
import { Box } from "@mantine/core";
66

@@ -11,19 +11,22 @@ import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homar
1111
import { BoardItemContent } from "../items/item-content";
1212
import { SectionProvider } from "../sections/section-context";
1313
import classes from "./mobile-board.module.css";
14-
import { createMobileBoardItems } from "./mobile-layout";
14+
import { createMobileBoardItems, mobileColumnCount } from "./mobile-layout";
1515

1616
export const MobileBoard = () => {
1717
const board = useRequiredBoard();
1818
const desktopLayout = getDesktopLayout(board);
1919
const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]);
20-
const rootSection = board.sections.find((section) => section.kind !== "dynamic");
20+
const rootSection = board.sections.find((section) => section.kind !== "dynamic") ?? {
21+
id: "mobile",
22+
kind: "empty" as const,
23+
xOffset: 0,
24+
yOffset: 0,
25+
};
2126
const wrapperRef = useRef<HTMLDivElement | null>(null);
2227
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
2328
const gridstackRef = useRef<GridStackInstance | null>(null);
2429

25-
if (!rootSection) return null;
26-
2730
return (
2831
<ReadOnlyEditModeProvider>
2932
<SectionProvider
@@ -34,7 +37,11 @@ export const MobileBoard = () => {
3437
refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef },
3538
}}
3639
>
37-
<Box className={classes.grid} data-mobile-board>
40+
<Box
41+
className={classes.grid}
42+
style={{ "--mobile-column-count": mobileColumnCount } as CSSProperties}
43+
data-mobile-board
44+
>
3845
{items.map((item) => (
3946
<Box
4047
key={item.id}

apps/nextjs/src/components/board/mobile/mobile-layout.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Board, DynamicSectionItem, ItemLayout, SectionItem } from "~/app/[locale]/boards/_types";
22

3-
const mobileColumnCount = 2;
3+
export const mobileColumnCount = 2;
44
const mobileMaxHeight = 3;
55

66
type PositionedElement = DynamicSectionItem | SectionItem;

apps/nextjs/src/components/board/use-mobile-board.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use client";
2+
3+
import type { PropsWithChildren } from "react";
4+
import { createContext, useContext } from "react";
5+
import { useMediaQuery } from "@mantine/hooks";
6+
7+
export const mobileBoardMediaQuery = "(max-width: 48em)";
8+
9+
const InitialMobileBoardContext = createContext<boolean | undefined>(undefined);
10+
11+
export const MobileBoardViewportProvider = ({
12+
initialIsMobile,
13+
children,
14+
}: PropsWithChildren<{ initialIsMobile: boolean }>) => (
15+
<InitialMobileBoardContext.Provider value={initialIsMobile}>{children}</InitialMobileBoardContext.Provider>
16+
);
17+
18+
export const useIsMobileBoard = () => {
19+
const initialIsMobile = useContext(InitialMobileBoardContext);
20+
return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true });
21+
};

packages/api/src/router/board.ts

Lines changed: 134 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -682,73 +682,147 @@ export const boardRouter = createTRPCRouter({
682682

683683
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
684684
}),
685-
saveLayout: protectedProcedure.input(boardSaveLayoutSchema).mutation(async ({ ctx, input }) => {
686-
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
685+
saveLayout: protectedProcedure
686+
.meta({
687+
mcp: {
688+
enabled: true,
689+
description:
690+
"Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)",
691+
},
692+
})
693+
.input(boardSaveLayoutSchema)
694+
.output(z.void())
695+
.mutation(async ({ ctx, input }) => {
696+
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
687697

688-
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
689-
const desktopLayout = board.layouts
690-
.toSorted(
691-
(layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint || layoutB.columnCount - layoutA.columnCount,
692-
)
693-
.at(0);
698+
const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
699+
const desktopLayout = board.layouts
700+
.toSorted(
701+
(layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint || layoutB.columnCount - layoutA.columnCount,
702+
)
703+
.at(0);
694704

695-
if (!desktopLayout) {
696-
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
697-
}
705+
if (!desktopLayout) {
706+
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" });
707+
}
698708

699-
if (desktopLayout.columnCount !== input.columnCount) {
700-
const updatedBoardLayout = getUpdatedBoardLayout(board, {
701-
previous: {
702-
layoutId: desktopLayout.id,
703-
columnCount: desktopLayout.columnCount,
704-
},
705-
current: {
706-
layoutId: desktopLayout.id,
707-
columnCount: input.columnCount,
708-
},
709-
});
709+
const updatedBoardLayout =
710+
desktopLayout.columnCount === input.columnCount
711+
? null
712+
: getUpdatedBoardLayout(board, {
713+
previous: {
714+
layoutId: desktopLayout.id,
715+
columnCount: desktopLayout.columnCount,
716+
},
717+
current: {
718+
layoutId: desktopLayout.id,
719+
columnCount: input.columnCount,
720+
},
721+
});
710722

711-
for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
712-
await ctx.db
713-
.update(itemLayouts)
714-
.set({
715-
height: itemSectionLayout.height,
716-
width: itemSectionLayout.width,
717-
xOffset: itemSectionLayout.xOffset,
718-
yOffset: itemSectionLayout.yOffset,
719-
sectionId: itemSectionLayout.sectionId,
720-
})
721-
.where(
722-
and(eq(itemLayouts.itemId, itemSectionLayout.itemId), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)),
723-
);
724-
}
723+
await handleTransactionsAsync(ctx.db, {
724+
async handleAsync(db, schema) {
725+
await db.transaction(async (transaction) => {
726+
for (const itemSectionLayout of updatedBoardLayout?.itemSectionLayouts ?? []) {
727+
await transaction
728+
.update(schema.itemLayouts)
729+
.set({
730+
height: itemSectionLayout.height,
731+
width: itemSectionLayout.width,
732+
xOffset: itemSectionLayout.xOffset,
733+
yOffset: itemSectionLayout.yOffset,
734+
sectionId: itemSectionLayout.sectionId,
735+
})
736+
.where(
737+
and(
738+
eq(schema.itemLayouts.itemId, itemSectionLayout.itemId),
739+
eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId),
740+
),
741+
);
742+
}
725743

726-
for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
727-
await ctx.db
728-
.update(sectionLayouts)
729-
.set({
730-
height: sectionLayout.height,
731-
width: sectionLayout.width,
732-
xOffset: sectionLayout.xOffset,
733-
yOffset: sectionLayout.yOffset,
734-
parentSectionId: sectionLayout.parentSectionId,
735-
})
736-
.where(
737-
and(
738-
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
739-
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
740-
),
741-
);
742-
}
743-
}
744+
for (const sectionLayout of updatedBoardLayout?.sectionLayouts ?? []) {
745+
await transaction
746+
.update(schema.sectionLayouts)
747+
.set({
748+
height: sectionLayout.height,
749+
width: sectionLayout.width,
750+
xOffset: sectionLayout.xOffset,
751+
yOffset: sectionLayout.yOffset,
752+
parentSectionId: sectionLayout.parentSectionId,
753+
})
754+
.where(
755+
and(
756+
eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
757+
eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
758+
),
759+
);
760+
}
744761

745-
await ctx.db
746-
.update(layouts)
747-
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
748-
.where(eq(layouts.id, desktopLayout.id));
762+
await transaction
763+
.update(schema.layouts)
764+
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
765+
.where(eq(schema.layouts.id, desktopLayout.id));
749766

750-
await ctx.db.delete(layouts).where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id))));
751-
}),
767+
await transaction
768+
.delete(schema.layouts)
769+
.where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id))));
770+
});
771+
},
772+
handleSync(db) {
773+
db.transaction((transaction) => {
774+
for (const itemSectionLayout of updatedBoardLayout?.itemSectionLayouts ?? []) {
775+
transaction
776+
.update(itemLayouts)
777+
.set({
778+
height: itemSectionLayout.height,
779+
width: itemSectionLayout.width,
780+
xOffset: itemSectionLayout.xOffset,
781+
yOffset: itemSectionLayout.yOffset,
782+
sectionId: itemSectionLayout.sectionId,
783+
})
784+
.where(
785+
and(
786+
eq(itemLayouts.itemId, itemSectionLayout.itemId),
787+
eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
788+
),
789+
)
790+
.run();
791+
}
792+
793+
for (const sectionLayout of updatedBoardLayout?.sectionLayouts ?? []) {
794+
transaction
795+
.update(sectionLayouts)
796+
.set({
797+
height: sectionLayout.height,
798+
width: sectionLayout.width,
799+
xOffset: sectionLayout.xOffset,
800+
yOffset: sectionLayout.yOffset,
801+
parentSectionId: sectionLayout.parentSectionId,
802+
})
803+
.where(
804+
and(
805+
eq(sectionLayouts.sectionId, sectionLayout.sectionId),
806+
eq(sectionLayouts.layoutId, sectionLayout.layoutId),
807+
),
808+
)
809+
.run();
810+
}
811+
812+
transaction
813+
.update(layouts)
814+
.set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 })
815+
.where(eq(layouts.id, desktopLayout.id))
816+
.run();
817+
818+
transaction
819+
.delete(layouts)
820+
.where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id))))
821+
.run();
822+
});
823+
},
824+
});
825+
}),
752826
savePartialBoardSettings: protectedProcedure
753827
.meta({
754828
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },

0 commit comments

Comments
 (0)