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..9c7be4ec2 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(""); @@ -54,15 +57,18 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { 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 openSwitcher = useCallback(() => { @@ -88,14 +94,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 +150,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { }; const results = getBoardSwitcherResults({ - availableBoardCount: availableBoards.length, + availableBoardCount: switcherBoards.length, filteredBoards, isError, isPending, @@ -152,6 +161,7 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { setActiveIndex, optionRefs, columnCount, + manageBoardsT, }); return ( @@ -161,54 +171,40 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { - - - - {t("title")} - - } - size={1280} - yOffset={32} - overlayProps={{ backgroundOpacity: 0.18, blur: 2 }} + withCloseButton={false} + centered + aria-label={t("title")} + size={`${columnCount * 15 + Math.max(0, columnCount - 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 +223,7 @@ interface BoardSwitcherResultsProps { setActiveIndex: (index: number) => void; optionRefs: RefObject>; columnCount: number; + manageBoardsT: ReturnType>; } const getBoardSwitcherResults = ({ @@ -241,6 +238,7 @@ const getBoardSwitcherResults = ({ setActiveIndex, optionRefs, columnCount, + manageBoardsT, }: BoardSwitcherResultsProps) => { if (isPending) { return ( @@ -287,7 +285,7 @@ const getBoardSwitcherResults = ({ } return ( - + {filteredBoards.map((board, index) => ( setActiveIndex(index)} onPointerEnter={() => setActiveIndex(index)} > - + - - - - - - + + + {board.name} + + {board.isHome && ( + + + + + + )} + {board.isMobileHome && ( + + + + + + )} + {board.creator && ( + + + + )} +