From e2f9fa871a12e51f8500eaf21fb31ad911c2507e Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 23 Jul 2026 02:10:42 +0200 Subject: [PATCH] fix: address board review and CI feedback --- .../app/[locale]/boards/_layout-creator.tsx | 46 ++-- .../board/mobile/mobile-board.module.css | 2 +- .../components/board/mobile/mobile-board.tsx | 19 +- .../components/board/mobile/mobile-layout.ts | 2 +- .../src/components/board/use-mobile-board.ts | 7 - .../src/components/board/use-mobile-board.tsx | 21 ++ packages/api/src/router/board.ts | 196 ++++++++++++------ 7 files changed, 197 insertions(+), 96 deletions(-) delete mode 100644 apps/nextjs/src/components/board/use-mobile-board.ts create mode 100644 apps/nextjs/src/components/board/use-mobile-board.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 3cddc6685..124fef581 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -1,4 +1,5 @@ import type { JSX, PropsWithChildren } from "react"; +import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; import { AppShellMain } from "@mantine/core"; import { TRPCError } from "@trpc/server"; @@ -6,8 +7,10 @@ import { TRPCError } from "@trpc/server"; import { auth } from "@homarr/auth/next"; import { BoardProvider } from "@homarr/boards/context"; import { EditModeProvider } from "@homarr/boards/edit-mode"; +import { userAgent } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { MobileBoardViewportProvider } from "~/components/board/use-mobile-board"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { ClientShell } from "~/components/layout/shell"; @@ -61,28 +64,31 @@ export const createBoardLayout = ({ throw error; }); - const colorScheme = await getCurrentColorSchemeAsync(); + const [colorScheme, requestHeaders] = await Promise.all([getCurrentColorSchemeAsync(), headers()]); + const initialIsMobile = userAgent(new Headers(requestHeaders)).device.type === "mobile"; return ( - - - - - - - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - - - - - - + + + + + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + + + + + ); }; diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index f598ba348..902e77372 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -1,6 +1,6 @@ .grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); grid-auto-flow: row; grid-auto-rows: min(calc((100vw - 42px) / 2), 12rem); gap: 10px; diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 1568b6aac..5e7a1029f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -1,6 +1,6 @@ "use client"; -import type { RefObject } from "react"; +import type { CSSProperties, RefObject } from "react"; import { useMemo, useRef } from "react"; import { Box } from "@mantine/core"; @@ -11,19 +11,22 @@ import type { GridItemHTMLElement, GridStack as GridStackInstance } from "@homar import { BoardItemContent } from "../items/item-content"; import { SectionProvider } from "../sections/section-context"; import classes from "./mobile-board.module.css"; -import { createMobileBoardItems } from "./mobile-layout"; +import { createMobileBoardItems, mobileColumnCount } from "./mobile-layout"; export const MobileBoard = () => { const board = useRequiredBoard(); const desktopLayout = getDesktopLayout(board); const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]); - const rootSection = board.sections.find((section) => section.kind !== "dynamic"); + const rootSection = board.sections.find((section) => section.kind !== "dynamic") ?? { + id: "mobile", + kind: "empty" as const, + xOffset: 0, + yOffset: 0, + }; const wrapperRef = useRef(null); const itemRefs = useRef>>({}); const gridstackRef = useRef(null); - if (!rootSection) return null; - return ( { refs: { wrapper: wrapperRef, items: itemRefs, gridstack: gridstackRef }, }} > - + {items.map((item) => ( useMediaQuery(mobileBoardMediaQuery); diff --git a/apps/nextjs/src/components/board/use-mobile-board.tsx b/apps/nextjs/src/components/board/use-mobile-board.tsx new file mode 100644 index 000000000..84eea8508 --- /dev/null +++ b/apps/nextjs/src/components/board/use-mobile-board.tsx @@ -0,0 +1,21 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useContext } from "react"; +import { useMediaQuery } from "@mantine/hooks"; + +export const mobileBoardMediaQuery = "(max-width: 48em)"; + +const InitialMobileBoardContext = createContext(undefined); + +export const MobileBoardViewportProvider = ({ + initialIsMobile, + children, +}: PropsWithChildren<{ initialIsMobile: boolean }>) => ( + {children} +); + +export const useIsMobileBoard = () => { + const initialIsMobile = useContext(InitialMobileBoardContext); + return useMediaQuery(mobileBoardMediaQuery, initialIsMobile, { getInitialValueInEffect: true }); +}; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index e02624596..a51313972 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -682,73 +682,147 @@ export const boardRouter = createTRPCRouter({ return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null); }), - saveLayout: protectedProcedure.input(boardSaveLayoutSchema).mutation(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); + saveLayout: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: + "Set a board's desktop column count and remove legacy responsive layouts. Requires modify permission. REQUIRED: id (board ID), columnCount (1-24)", + }, + }) + .input(boardSaveLayoutSchema) + .output(z.void()) + .mutation(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); - const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); - const desktopLayout = board.layouts - .toSorted( - (layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint || layoutB.columnCount - layoutA.columnCount, - ) - .at(0); + const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); + const desktopLayout = board.layouts + .toSorted( + (layoutA, layoutB) => layoutB.breakpoint - layoutA.breakpoint || layoutB.columnCount - layoutA.columnCount, + ) + .at(0); - if (!desktopLayout) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" }); - } + if (!desktopLayout) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a layout" }); + } - if (desktopLayout.columnCount !== input.columnCount) { - const updatedBoardLayout = getUpdatedBoardLayout(board, { - previous: { - layoutId: desktopLayout.id, - columnCount: desktopLayout.columnCount, + const updatedBoardLayout = + desktopLayout.columnCount === input.columnCount + ? null + : getUpdatedBoardLayout(board, { + previous: { + layoutId: desktopLayout.id, + columnCount: desktopLayout.columnCount, + }, + current: { + layoutId: desktopLayout.id, + columnCount: input.columnCount, + }, + }); + + await handleTransactionsAsync(ctx.db, { + async handleAsync(db, schema) { + await db.transaction(async (transaction) => { + for (const itemSectionLayout of updatedBoardLayout?.itemSectionLayouts ?? []) { + await transaction + .update(schema.itemLayouts) + .set({ + height: itemSectionLayout.height, + width: itemSectionLayout.width, + xOffset: itemSectionLayout.xOffset, + yOffset: itemSectionLayout.yOffset, + sectionId: itemSectionLayout.sectionId, + }) + .where( + and( + eq(schema.itemLayouts.itemId, itemSectionLayout.itemId), + eq(schema.itemLayouts.layoutId, itemSectionLayout.layoutId), + ), + ); + } + + for (const sectionLayout of updatedBoardLayout?.sectionLayouts ?? []) { + await transaction + .update(schema.sectionLayouts) + .set({ + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + parentSectionId: sectionLayout.parentSectionId, + }) + .where( + and( + eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId), + eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId), + ), + ); + } + + await transaction + .update(schema.layouts) + .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) + .where(eq(schema.layouts.id, desktopLayout.id)); + + await transaction + .delete(schema.layouts) + .where(and(eq(schema.layouts.boardId, board.id), not(eq(schema.layouts.id, desktopLayout.id)))); + }); }, - current: { - layoutId: desktopLayout.id, - columnCount: input.columnCount, + handleSync(db) { + db.transaction((transaction) => { + for (const itemSectionLayout of updatedBoardLayout?.itemSectionLayouts ?? []) { + transaction + .update(itemLayouts) + .set({ + height: itemSectionLayout.height, + width: itemSectionLayout.width, + xOffset: itemSectionLayout.xOffset, + yOffset: itemSectionLayout.yOffset, + sectionId: itemSectionLayout.sectionId, + }) + .where( + and( + eq(itemLayouts.itemId, itemSectionLayout.itemId), + eq(itemLayouts.layoutId, itemSectionLayout.layoutId), + ), + ) + .run(); + } + + for (const sectionLayout of updatedBoardLayout?.sectionLayouts ?? []) { + transaction + .update(sectionLayouts) + .set({ + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + parentSectionId: sectionLayout.parentSectionId, + }) + .where( + and( + eq(sectionLayouts.sectionId, sectionLayout.sectionId), + eq(sectionLayouts.layoutId, sectionLayout.layoutId), + ), + ) + .run(); + } + + transaction + .update(layouts) + .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) + .where(eq(layouts.id, desktopLayout.id)) + .run(); + + transaction + .delete(layouts) + .where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id)))) + .run(); + }); }, }); - - for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) { - await ctx.db - .update(itemLayouts) - .set({ - height: itemSectionLayout.height, - width: itemSectionLayout.width, - xOffset: itemSectionLayout.xOffset, - yOffset: itemSectionLayout.yOffset, - sectionId: itemSectionLayout.sectionId, - }) - .where( - and(eq(itemLayouts.itemId, itemSectionLayout.itemId), eq(itemLayouts.layoutId, itemSectionLayout.layoutId)), - ); - } - - for (const sectionLayout of updatedBoardLayout.sectionLayouts) { - await ctx.db - .update(sectionLayouts) - .set({ - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - parentSectionId: sectionLayout.parentSectionId, - }) - .where( - and( - eq(sectionLayouts.sectionId, sectionLayout.sectionId), - eq(sectionLayouts.layoutId, sectionLayout.layoutId), - ), - ); - } - } - - await ctx.db - .update(layouts) - .set({ name: "Base", columnCount: input.columnCount, breakpoint: 0 }) - .where(eq(layouts.id, desktopLayout.id)); - - await ctx.db.delete(layouts).where(and(eq(layouts.boardId, board.id), not(eq(layouts.id, desktopLayout.id)))); - }), + }), savePartialBoardSettings: protectedProcedure .meta({ openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },