From 1b843817a4596770140851ff7a2dc2196b9e43f1 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 22 Aug 2026 17:21:10 +0200 Subject: [PATCH] feat: polish board switcher (#6670) --- apps/docs/docs/management/boards/index.mdx | 7 +- .../board/board-layout-thumbnail.module.css | 9 +- .../board/board-layout-thumbnail.tsx | 8 +- .../board/board-switcher.module.css | 25 +++ .../src/components/board/board-switcher.tsx | 153 +++++++++++------- packages/api/src/router/board.ts | 9 +- packages/api/src/router/test/board.spec.ts | 6 +- 7 files changed, 138 insertions(+), 79 deletions(-) diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 9ddf9732c..651cd6e7c 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -27,9 +27,10 @@ opening it. Containers include their visible widgets and app icons instead of ap Names of boards must be unique because this will be used in the URL to load the desired board. -Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows -structural previews of the other boards you can access. Start typing to filter by name, use the arrow keys to move -through the results, and press `Enter` to open the selected board. +Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural +previews of every board you can access, with your current board at the end. Home-board markers and creator avatars +match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the +arrow keys to move through the results and press `Enter` to open the selected board. ## Create a new 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 8d7e38dd3..7ef19c047 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css @@ -1,5 +1,6 @@ .canvas { - min-height: 8rem; + width: 100%; + min-width: 0; aspect-ratio: 16 / 7; overflow: hidden; color: var(--mantine-color-dimmed); @@ -83,9 +84,3 @@ 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 48ba6fc6b..c73482aac 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -11,11 +11,12 @@ import classes from "./board-layout-thumbnail.module.css"; interface BoardLayoutThumbnailProps { preview: BoardPreviewData | null; label: string; + fitFullLayout?: boolean; } const maxThumbnailRows = 12; -export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => { +export const BoardLayoutThumbnail = ({ preview, label, fitFullLayout = false }: BoardLayoutThumbnailProps) => { const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0); if (!preview || !layout) { return ( @@ -26,6 +27,7 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro } const elements = projectBoardLayout(preview, layout, layout); + const thumbnailRowLimit = fitFullLayout ? Number.POSITIVE_INFINITY : maxThumbnailRows; const roots = preview.sections .filter((section) => section.kind === "empty") .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id)); @@ -65,12 +67,12 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro element.xOffset >= 0 && element.xOffset < columnCount && element.yOffset >= 0 && - element.yOffset < maxThumbnailRows && + element.yOffset < thumbnailRowLimit && element.width > 0 && element.height > 0, ); const rowCount = Math.min( - maxThumbnailRows, + thumbnailRowLimit, Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)), ); diff --git a/apps/nextjs/src/components/board/board-switcher.module.css b/apps/nextjs/src/components/board/board-switcher.module.css index 41894a29b..6843fe497 100644 --- a/apps/nextjs/src/components/board/board-switcher.module.css +++ b/apps/nextjs/src/components/board/board-switcher.module.css @@ -1,7 +1,26 @@ +.modalContent { + overflow: hidden; + border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent); + border-radius: rem(24px); + background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent); + box-shadow: 0 rem(18px) rem(56px) rgb(0 0 0 / 22%); + backdrop-filter: blur(rem(20px)) saturate(140%); +} + +.modalBody { + padding: var(--mantine-spacing-sm); +} + .content { outline: none; } +.searchInput { + border-color: color-mix(in srgb, var(--mantine-color-default-border) 72%, transparent); + background: color-mix(in srgb, var(--mantine-color-body) 54%, transparent); + backdrop-filter: blur(rem(8px)); +} + .option { min-width: 0; border-radius: var(--mantine-radius-md); @@ -11,6 +30,12 @@ .card { height: 100%; overflow: hidden; + box-shadow: var(--mantine-shadow-sm); +} + +.metadataBadge { + width: rem(22px); + padding-inline: 0; } .option:hover .card, diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx index bb309b6b4..7d20f5bfc 100644 --- a/apps/nextjs/src/components/board/board-switcher.tsx +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -3,29 +3,31 @@ import type { KeyboardEvent, ReactNode, RefObject } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Badge, Card, Center, Group, - Kbd, Loader, Modal, ScrollArea, SimpleGrid, Stack, Text, + TextInput, ThemeIcon, + Tooltip, UnstyledButton, useMatches, VisuallyHidden, } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; -import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } from "@tabler/icons-react"; +import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useOptionalBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; +import { Link, UserAvatar } from "@homarr/ui"; import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; @@ -39,6 +41,7 @@ interface BoardSwitcherProps { export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { const t = useI18n("board.action.switcher"); + const manageBoardsT = useI18n("management.page.board"); const currentBoard = useOptionalBoard(); const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); @@ -50,20 +53,27 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { data: boards = [], isPending, isError, - } = clientApi.board.getManageOverview.useQuery(undefined, { - enabled: isOpen, - }); + } = clientApi.board.getManageOverview.useQuery( + { fullPreview: true }, + { + enabled: isOpen, + }, + ); - const availableBoards = useMemo( - () => boards.filter((board) => board.id !== currentBoard?.id), + const switcherBoards = useMemo( + () => [ + ...boards.filter((board) => board.id !== currentBoard?.id), + ...boards.filter((board) => board.id === currentBoard?.id), + ], [boards, currentBoard?.id], ); const filteredBoards = useMemo(() => { const normalizedSearch = search.trim().toLocaleLowerCase(); - if (normalizedSearch.length === 0) return availableBoards; - return availableBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch)); - }, [availableBoards, search]); + if (normalizedSearch.length === 0) return switcherBoards; + return switcherBoards.filter((board) => board.name.toLocaleLowerCase().includes(normalizedSearch)); + }, [search, switcherBoards]); const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount)); + const modalColumnCount = Math.max(1, Math.min(switcherBoards.length, responsiveColumnCount)); const openSwitcher = useCallback(() => { setIsOpen(true); @@ -73,7 +83,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { setSearch(""); setActiveIndex(0); }, []); - const preloadBoards = () => void utils.board.getManageOverview.prefetch(); + const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true }); useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); @@ -88,14 +98,17 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { }, [activeIndex, filteredBoards.length, isOpen]); const handleModalKeyDown = (event: KeyboardEvent) => { - if (event.key === "Backspace") { + const isSearchInput = + event.target instanceof HTMLInputElement && event.target.dataset.boardSwitcherSearch !== undefined; + + if (!isSearchInput && event.key === "Backspace") { event.preventDefault(); setSearch((current) => current.slice(0, -1)); setActiveIndex(0); return; } - if (event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) { + if (!isSearchInput && event.key.length === 1 && !event.altKey && !event.ctrlKey && !event.metaKey) { event.preventDefault(); setSearch((current) => `${current}${event.key}`); setActiveIndex(0); @@ -141,7 +154,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { }; const results = getBoardSwitcherResults({ - availableBoardCount: availableBoards.length, + availableBoardCount: switcherBoards.length, filteredBoards, isError, isPending, @@ -152,6 +165,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { setActiveIndex, optionRefs, columnCount, + manageBoardsT, }); return ( @@ -161,54 +175,40 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { - - - - {t("title")} - - } - size={1280} - yOffset={32} - overlayProps={{ backgroundOpacity: 0.18, blur: 2 }} + withCloseButton={false} + yOffset="15vh" + aria-label={t("title")} + size={`${modalColumnCount * 15 + Math.max(0, modalColumnCount - 1) * 0.75}rem`} + overlayProps={{ backgroundOpacity: 0, blur: 2 }} transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }} + classNames={{ content: classes.modalContent, body: classes.modalBody }} > {t("keyboard.instructions")} - + { + setSearch(event.currentTarget.value); + setActiveIndex(0); + }} + leftSection={} + placeholder={t("title")} + aria-label={t("title")} + aria-controls="board-switcher-results" + radius="xl" + classNames={{ input: classes.searchInput }} + /> + {results} - - - {currentBoard && ( - - {t("currentHidden", { name: currentBoard.name })} - - )} - - - - - - - - - {t("keyboard.navigate")} - - Enter - - {t("keyboard.open")} - - - @@ -227,6 +227,7 @@ interface BoardSwitcherResultsProps { setActiveIndex: (index: number) => void; optionRefs: RefObject>; columnCount: number; + manageBoardsT: ReturnType>; } const getBoardSwitcherResults = ({ @@ -241,6 +242,7 @@ const getBoardSwitcherResults = ({ setActiveIndex, optionRefs, columnCount, + manageBoardsT, }: BoardSwitcherResultsProps) => { if (isPending) { return ( @@ -287,7 +289,7 @@ const getBoardSwitcherResults = ({ } return ( - + {filteredBoards.map((board, index) => ( setActiveIndex(index)} onPointerEnter={() => setActiveIndex(index)} > - + - - - - - - + + + {board.name} + + {board.isHome && ( + + + + + + )} + {board.isMobileHome && ( + + + + + + )} + {board.creator && ( + + + + )} + diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 617c9b270..345dd1161 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -86,6 +86,7 @@ interface BoardItemPlacementRectangle { const boardItemPlacementTails = new Map>(); const maxManageOverviewPreviewRows = 12; +const manageOverviewInputSchema = z.object({ fullPreview: z.boolean().default(false) }).optional(); const serializeBoardItemPlacementAsync = async (boardId: string, operation: () => Promise) => { const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve(); @@ -225,7 +226,7 @@ export const boardRouter = createTRPCRouter({ isMobileHome: currentUser?.mobileHomeBoardId === board.id, })); }), - getManageOverview: publicProcedure.query(async ({ ctx }) => { + getManageOverview: publicProcedure.input(manageOverviewInputSchema).query(async ({ ctx, input }) => { const userId = ctx.session?.user.id; const { boardIds, currentUser, groupMemberships } = await getBoardAccessContextAsync(ctx.db, userId); const groupPermissionWhere = getBoardGroupPermissionWhere(groupMemberships); @@ -306,7 +307,7 @@ export const boardRouter = createTRPCRouter({ inArray(itemLayouts.sectionId, rootSectionIds), gte(itemLayouts.xOffset, 0), gte(itemLayouts.yOffset, 0), - lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), gt(itemLayouts.width, 0), gt(itemLayouts.height, 0), ), @@ -331,7 +332,7 @@ export const boardRouter = createTRPCRouter({ inArray(sectionLayouts.parentSectionId, rootSectionIds), gte(sectionLayouts.xOffset, 0), gte(sectionLayouts.yOffset, 0), - lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows), + input?.fullPreview ? undefined : lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows), gt(sectionLayouts.width, 0), gt(sectionLayouts.height, 0), ), @@ -366,7 +367,7 @@ export const boardRouter = createTRPCRouter({ inArray(itemLayouts.sectionId, previewContainerSectionIds), gte(itemLayouts.xOffset, 0), gte(itemLayouts.yOffset, 0), - lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), + input?.fullPreview ? undefined : lt(itemLayouts.yOffset, maxManageOverviewPreviewRows), gt(itemLayouts.width, 0), gt(itemLayouts.height, 0), ), diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index c194dd5ca..b8411ca1f 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -337,7 +337,7 @@ describe("getManageOverview", () => { expect(overview.map((board) => board.id)).not.toContain(hiddenBoardId); }); - test("returns one compact base-layout preview for each accessible board", async () => { + test("returns compact previews by default and full previews on request", async () => { const db = createDb(); const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); const boardId = createId(); @@ -487,6 +487,10 @@ describe("getManageOverview", () => { expect(result[0]?.preview?.layouts).toHaveLength(1); expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, nestedItemId]); expect(result[0]?.preview?.sections).toHaveLength(2); + + const fullResult = await caller.getManageOverview({ fullPreview: true }); + + expect(fullResult[0]?.preview?.items.map((item) => item.id)).toEqual([itemId, offscreenItemId, nestedItemId]); }); test("includes app and widget icons in compact previews", async () => {