From 8a0829301661e6c91105be3de9b837cb1a646c55 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 22 Aug 2026 11:14:41 +0200 Subject: [PATCH 1/4] fix: restore bookmark grid fill (#6659) * fix: restore bookmark grid fill * fix: use scroll area viewport class --- .../widgets/src/bookmarks/bookmark.module.css | 12 +++++ packages/widgets/src/bookmarks/component.tsx | 50 ++++++++----------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/widgets/src/bookmarks/bookmark.module.css b/packages/widgets/src/bookmarks/bookmark.module.css index 807b90e48..e82a66de2 100644 --- a/packages/widgets/src/bookmarks/bookmark.module.css +++ b/packages/widgets/src/bookmarks/bookmark.module.css @@ -23,3 +23,15 @@ .bookmarkButton:focus-visible .bookmarkIcon { background-color: var(--mantine-color-iconColor-filled-hover); } + +.compactScrollViewport > div { + display: flex !important; + width: 100%; + min-height: 100%; +} + +.compactScrollContent { + flex: 1; + min-width: 100%; + min-height: 100%; +} diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index de8cd67ee..e8311219d 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -2,19 +2,7 @@ import type { CSSProperties } from "react"; import { useMemo } from "react"; -import { - Anchor, - Box, - Card, - Flex, - Group, - ScrollArea, - SimpleGrid, - Stack, - Text, - Title, - UnstyledButton, -} from "@mantine/core"; +import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core"; import { IconLink } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -42,6 +30,7 @@ export interface CompactBookmarkLayout { columns: number; hideHostname: boolean; hideTitle: boolean; + minimumItemWidth: number; minimumItemSize: number; } @@ -64,6 +53,7 @@ export function getCompactBookmarkLayout( columns, hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96), hideTitle: cellWidth < 64 || cellHeight < 40, + minimumItemWidth: targetWidth, minimumItemSize: usesHorizontalItems ? 48 : 96, }; } @@ -139,12 +129,17 @@ export default function BookmarksWidget({ /> )} {displayMode !== "advanced" && ( - - + +
{options.layout === "grid" || options.layout === "gridHorizontal" ? ( )} - +
)} @@ -340,7 +335,7 @@ interface GridLayoutProps { withBorder: boolean; itemDirection: "horizontal" | "vertical"; hasIconColor: boolean; - columns: number; + minimumItemWidth: number; minimumItemHeight: number; } @@ -353,20 +348,13 @@ const GridLayout = ({ withBorder, itemDirection, hasIconColor, - columns, + minimumItemWidth, minimumItemHeight, }: GridLayoutProps) => { const board = useRequiredBoard(); return ( - + {data.map((app) => { const href = getSafeAppHref(app.href); return ( @@ -377,7 +365,11 @@ const GridLayout = ({ target={href ? (openNewTab ? "_blank" : "_self") : undefined} rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined} key={app.id} - h="100%" + style={{ + flex: `1 0 calc(${minimumItemWidth}px * var(--mantine-scale, 1))`, + minWidth: 0, + minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`, + }} > ); })} - + ); }; From 2accdc2fad39f2c5c6c86c5d5dbbab0469a338cf Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 22 Aug 2026 11:15:40 +0200 Subject: [PATCH 2/4] feat: rework board switcher (#6660) * feat: rework board switcher * fix: address board switcher review --- .../after-the-installation.mdx | 6 +- apps/docs/docs/management/boards/index.mdx | 4 + .../boards/(content)/_header-actions.tsx | 60 +--- .../board/board-switcher.module.css | 21 ++ .../src/components/board/board-switcher.tsx | 331 ++++++++++++++++++ .../src/components/onboarding/board-tour.tsx | 10 - .../src/components/user-avatar-menu.tsx | 188 +++++----- packages/translation/src/lang/en.json | 19 + 8 files changed, 482 insertions(+), 157 deletions(-) create mode 100644 apps/nextjs/src/components/board/board-switcher.module.css create mode 100644 apps/nextjs/src/components/board/board-switcher.tsx diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index bcf9b92fa..dcd800dc8 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -132,8 +132,8 @@ Here you have the following options in the header: - **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page) - **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board. - **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...). -- **Board switcher**: Use this button to open another board that you can access. -- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions. +- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open + it directly. The current board is left out of the results. When entering the edit mode, you'll get some additional buttons to work with: @@ -369,7 +369,7 @@ Each page highlights the list view and the create action so you know where to fi ### Board tour -When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu. +When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu. ### Tour controls diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 92d709e3c..9ddf9732c 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -27,6 +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. + ## Create a new board When creating a board, the availability of the board name will be checked for you. diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index f85d78d27..476706ded 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -3,18 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; -import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; -import { - IconDeviceFloppy, - IconLayoutBoard, - IconPencil, - IconPencilOff, - IconReplace, - IconSettings, - IconX, -} from "@tabler/icons-react"; +import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -25,8 +16,6 @@ import { hotkeys } from "@homarr/definitions"; import { useConfirmModal } from "@homarr/modals"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; -import { Link } from "@homarr/ui"; - import { useBoardPermissions } from "~/components/board/permissions/client"; import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader"; import { HeaderButton } from "~/components/layout/header/button"; @@ -56,9 +45,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool const t = useI18n("board"); const { hasChangeAccess } = useBoardPermissions(board); - if (!hasChangeAccess) { - return ; - } + if (!hasChangeAccess) return null; return ( <> @@ -73,8 +60,6 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool )} - - ); }; @@ -235,47 +220,6 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { ); }; -const SelectBoardsMenu = () => { - const t = useI18n("board"); - const [isOpen, setIsOpen] = useState(false); - const utils = clientApi.useUtils(); - const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen }); - const preloadBoards = () => void utils.board.getAllBoards.prefetch(); - - return ( - - - - - - - - - - - {isPending && ( -
- -
- )} - {boards.map((board) => ( - } - > - {board.name} - - ))} -
-
-
-
-
- ); -}; - const anchorSelector = "a[href]:not([target='_blank'])"; const usePreventLeaveWithDirty = (isDirty: boolean) => { const tBoard = useI18n("board"); diff --git a/apps/nextjs/src/components/board/board-switcher.module.css b/apps/nextjs/src/components/board/board-switcher.module.css new file mode 100644 index 000000000..41894a29b --- /dev/null +++ b/apps/nextjs/src/components/board/board-switcher.module.css @@ -0,0 +1,21 @@ +.content { + outline: none; +} + +.option { + min-width: 0; + border-radius: var(--mantine-radius-md); + outline: none; +} + +.card { + height: 100%; + overflow: hidden; +} + +.option:hover .card, +.option:focus-visible .card, +.option[data-active="true"] .card { + border-color: var(--mantine-primary-color-filled); + box-shadow: var(--mantine-shadow-md); +} diff --git a/apps/nextjs/src/components/board/board-switcher.tsx b/apps/nextjs/src/components/board/board-switcher.tsx new file mode 100644 index 000000000..bb309b6b4 --- /dev/null +++ b/apps/nextjs/src/components/board/board-switcher.tsx @@ -0,0 +1,331 @@ +"use client"; + +import type { KeyboardEvent, ReactNode, RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Card, + Center, + Group, + Kbd, + Loader, + Modal, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + UnstyledButton, + useMatches, + VisuallyHidden, +} from "@mantine/core"; +import { useHotkeys } from "@mantine/hooks"; +import { IconArrowDown, IconArrowRight, IconLayoutBoard, IconReplace } 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 { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail"; + +import classes from "./board-switcher.module.css"; + +export const boardSwitcherHotkey = "shift+c"; + +interface BoardSwitcherProps { + children: (controls: { open: () => void; preload: () => void; hotkey: string }) => ReactNode; +} + +export const BoardSwitcher = ({ children }: BoardSwitcherProps) => { + const t = useI18n("board.action.switcher"); + const currentBoard = useOptionalBoard(); + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const responsiveColumnCount = useMatches({ base: 1, sm: 2, lg: 3, xl: 4 }); + const optionRefs = useRef>([]); + const utils = clientApi.useUtils(); + const { + data: boards = [], + isPending, + isError, + } = clientApi.board.getManageOverview.useQuery(undefined, { + enabled: isOpen, + }); + + const availableBoards = useMemo( + () => 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]); + const columnCount = Math.max(1, Math.min(filteredBoards.length, responsiveColumnCount)); + + const openSwitcher = useCallback(() => { + setIsOpen(true); + }, []); + const closeSwitcher = useCallback(() => { + setIsOpen(false); + setSearch(""); + setActiveIndex(0); + }, []); + const preloadBoards = () => void utils.board.getManageOverview.prefetch(); + + useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]); + + useEffect(() => { + if (!isOpen || filteredBoards.length === 0) return; + const normalizedIndex = Math.min(activeIndex, filteredBoards.length - 1); + if (normalizedIndex !== activeIndex) { + setActiveIndex(normalizedIndex); + return; + } + optionRefs.current[normalizedIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex, filteredBoards.length, isOpen]); + + const handleModalKeyDown = (event: KeyboardEvent) => { + if (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) { + event.preventDefault(); + setSearch((current) => `${current}${event.key}`); + setActiveIndex(0); + return; + } + + if (filteredBoards.length === 0) return; + + if (event.key === "Enter") { + const target = event.target; + if (target instanceof Element && target.closest("[data-board-switcher-option]")) return; + event.preventDefault(); + optionRefs.current[activeIndex]?.click(); + return; + } + + let nextIndex = activeIndex; + if (event.key === "ArrowRight") { + nextIndex = (activeIndex + 1) % filteredBoards.length; + } else if (event.key === "ArrowLeft") { + nextIndex = (activeIndex - 1 + filteredBoards.length) % filteredBoards.length; + } else if (event.key === "ArrowDown") { + const nextRowIndex = activeIndex + columnCount; + nextIndex = nextRowIndex; + if (nextRowIndex >= filteredBoards.length) nextIndex = activeIndex % columnCount; + } else if (event.key === "ArrowUp") { + const previousRowIndex = activeIndex - columnCount; + if (previousRowIndex >= 0) { + nextIndex = previousRowIndex; + } else { + const lastRowStart = Math.floor((filteredBoards.length - 1) / columnCount) * columnCount; + const lastIndexInColumn = lastRowStart + activeIndex; + nextIndex = lastIndexInColumn; + if (lastIndexInColumn >= filteredBoards.length) nextIndex -= columnCount; + } + } else { + return; + } + + event.preventDefault(); + setActiveIndex(nextIndex); + requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus({ preventScroll: true })); + }; + + const results = getBoardSwitcherResults({ + availableBoardCount: availableBoards.length, + filteredBoards, + isError, + isPending, + search, + t, + activeIndex, + closeSwitcher, + setActiveIndex, + optionRefs, + columnCount, + }); + + return ( + <> + {children({ open: openSwitcher, preload: preloadBoards, hotkey: boardSwitcherHotkey })} + + + + + + {t("title")} + + } + size={1280} + yOffset={32} + overlayProps={{ backgroundOpacity: 0.18, blur: 2 }} + transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }} + > + + {t("keyboard.instructions")} + + {results} + + + + {currentBoard && ( + + {t("currentHidden", { name: currentBoard.name })} + + )} + + + + + + + + + {t("keyboard.navigate")} + + Enter + + {t("keyboard.open")} + + + + + + + ); +}; + +interface BoardSwitcherResultsProps { + availableBoardCount: number; + filteredBoards: RouterOutputs["board"]["getManageOverview"]; + isError: boolean; + isPending: boolean; + search: string; + t: ReturnType>; + activeIndex: number; + closeSwitcher: () => void; + setActiveIndex: (index: number) => void; + optionRefs: RefObject>; + columnCount: number; +} + +const getBoardSwitcherResults = ({ + availableBoardCount, + filteredBoards, + isError, + isPending, + search, + t, + activeIndex, + closeSwitcher, + setActiveIndex, + optionRefs, + columnCount, +}: BoardSwitcherResultsProps) => { + if (isPending) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ + {t("error")} + +
+ ); + } + + if (availableBoardCount === 0) { + return ( +
+ + + + + {t("empty.title")} + + {t("empty.description")} + + +
+ ); + } + + if (filteredBoards.length === 0) { + return ( +
+ + {t("search.empty", { search })} + +
+ ); + } + + return ( + + {filteredBoards.map((board, index) => ( + { + optionRefs.current[index] = node; + }} + className={classes.option} + w="100%" + maw={320} + mx="auto" + component={Link} + href={`/boards/${encodeURIComponent(board.name)}`} + data-active={index === activeIndex} + data-board-switcher-option + onClick={closeSwitcher} + onFocus={() => setActiveIndex(index)} + onPointerEnter={() => setActiveIndex(index)} + > + + + + + + + + + + + {board.name} + + + + + + ))} + + ); +}; diff --git a/apps/nextjs/src/components/onboarding/board-tour.tsx b/apps/nextjs/src/components/onboarding/board-tour.tsx index 3b5af7418..9429c9e52 100644 --- a/apps/nextjs/src/components/onboarding/board-tour.tsx +++ b/apps/nextjs/src/components/onboarding/board-tour.tsx @@ -66,16 +66,6 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => { /> ), }, - { - id: "board-switcher", - title: t("switcher.title"), - content: ( - - ), - }, { id: "board-user-menu", title: t("userMenu.title"), diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 5f6165db8..9ec551171 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -8,6 +8,7 @@ import { IconHome, IconLogin, IconLogout, + IconReplace, IconSettings, IconRobot, IconTool, @@ -22,6 +23,7 @@ import { Link } from "@homarr/ui"; import { useAuthContext } from "~/app/[locale]/_client-providers/session"; import { useOptionalHomarrAssistant } from "./assistant/assistant-context"; +import { BoardSwitcher } from "./board/board-switcher"; import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox"; import { CurrentLanguageCombobox } from "./language/current-language-combobox"; import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal"; @@ -41,6 +43,7 @@ const formatHotkeyLabel = (hotkey: string, modifierLabel: string) => export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => { const t = useI18n("common.userAvatar.menu"); + const tBoard = useI18n("board"); const session = useSession(); const { logoutUrl } = useAuthContext(); @@ -56,98 +59,111 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: }, [logoutUrl]); return ( - // We use keepMounted so we can add event listeners to prevent navigating away without saving the board - - - - }> - {t("homeBoard")} - - - - - - - - - - - {Boolean(session.data) && ( - <> - {assistant?.enabled && ( - : - } - rightSection={ - assistant.unreadCount > 0 ? ( - - {assistant.unreadCount > 99 ? "99+" : assistant.unreadCount} - - ) : assistant.isRunning ? ( - - {t("assistantThinking")} - - ) : ( - {formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))} - ) - } - onClick={assistant.open} - > - {t("assistant")} - - )} + + {({ open, preload, hotkey }) => ( + // We use keepMounted so we can add event listeners to prevent navigating away without saving the board + + + + }> + {t("homeBoard")} + } + leftSection={} + rightSection={{formatHotkeyLabel(hotkey, t("modifier"))}} + onClick={open} + onFocus={preload} + onPointerEnter={preload} > - {t("preferences")} + {tBoard("action.switch")} + - }> - {t("management")} + + - {isDockerEnabled && ( - } onClick={() => openDockerModal()}> - {invariantTechnicalLabels.docker} + + + + + {Boolean(session.data) && ( + <> + {assistant?.enabled && ( + : + } + rightSection={ + assistant.unreadCount > 0 ? ( + + {assistant.unreadCount > 99 ? "99+" : assistant.unreadCount} + + ) : assistant.isRunning ? ( + + {t("assistantThinking")} + + ) : ( + {formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))} + ) + } + onClick={assistant.open} + > + {t("assistant")} + + )} + } + > + {t("preferences")} + + + }> + {t("management")} + + {isDockerEnabled && ( + } onClick={() => openDockerModal()}> + {invariantTechnicalLabels.docker} + + )} + + )} + + {session.status === "authenticated" ? ( + } color="red"> + {t("logout")} + + ) : ( + }> + {t("login")} )} - - )} - - {session.status === "authenticated" ? ( - } color="red"> - {t("logout")} - - ) : ( - }> - {t("login")} - - )} - - - - ) : assistant?.unreadCount ? ( - assistant.unreadCount > 99 ? ( - "99+" - ) : ( - assistant.unreadCount - ) - ) : undefined - } - > - {children} - - - + + + + ) : assistant?.unreadCount ? ( + assistant.unreadCount > 99 ? ( + "99+" + ) : ( + assistant.unreadCount + ) + ) : undefined + } + > + {children} + + + + )} + ); }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 65b423d4f..b67a8a6ab 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5130,6 +5130,25 @@ "addContent": "Add board content", "settings": "Board settings", "switch": "Switch board", + "switcher": { + "title": "Switch board", + "search": { + "empty": "No boards match \"{search}\"." + }, + "results": "Available boards", + "preview": "Preview of {name} with {count} items", + "currentHidden": "Current board: {name}", + "keyboard": { + "navigate": "Navigate", + "open": "Open", + "instructions": "Type to filter boards. Use the arrow keys to move through results and Enter to open a board." + }, + "empty": { + "title": "No other boards", + "description": "There are no other boards you can access." + }, + "error": "Boards could not be loaded." + }, "create": { "availability": { "checking": "Checking availability…", From 6297ca4b16b6bed6f4458604720c8b1f2f03fbef Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 22 Aug 2026 11:48:33 +0200 Subject: [PATCH 3/4] fix: keep media stream status at table edge (#6661) --- packages/widgets/src/media-server/component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 6baffca83..210e4dd30 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -147,7 +147,7 @@ function StreamTableHeader({ const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; return ( - + {sortable ? ( onSort(column)}> From f46ccadbe4dead1a40af33212148f8198bc2086d Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 22 Aug 2026 13:08:27 +0200 Subject: [PATCH 4/4] feat: redesign demo dashboard (#6663) --- .../images/demo-dashboard-background.svg | 53 +++ packages/db/migrations/seed.ts | 305 ++++++++++++++---- packages/db/test/sqlite-migration.spec.ts | 190 +++++++++++ 3 files changed, 487 insertions(+), 61 deletions(-) create mode 100644 apps/nextjs/public/images/demo-dashboard-background.svg diff --git a/apps/nextjs/public/images/demo-dashboard-background.svg b/apps/nextjs/public/images/demo-dashboard-background.svg new file mode 100644 index 000000000..8dd135573 --- /dev/null +++ b/apps/nextjs/public/images/demo-dashboard-background.svg @@ -0,0 +1,53 @@ + + Abstract homelab network + A dark indigo field with subtle connected nodes and cyan signal paths. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts index 3453a75fd..57d3fea51 100644 --- a/packages/db/migrations/seed.ts +++ b/packages/db/migrations/seed.ts @@ -514,6 +514,9 @@ const seedDefaultBoardAsync = async (db: Database) => { interface DemoWidget { kind: WidgetKind; + section?: "network" | "right"; + xOffset: number; + yOffset: number; width: number; height: number; needsIntegration: boolean; @@ -562,9 +565,9 @@ const demoApps = [ href: "https://portainer.io", }, { - name: "Home Assistant", - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg", - href: "https://home-assistant.io", + name: "Uptime Kuma", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/uptime-kuma.svg", + href: "https://uptime.kuma.pet", }, { name: "Nextcloud", @@ -583,69 +586,166 @@ const demoApps = [ }, ] as const; -const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [ - // Row 1: calendar + downloads + clock = 12 - { kind: "calendar", width: 5, height: 3, needsIntegration: true }, - { kind: "downloads", width: 5, height: 3, needsIntegration: true }, - { kind: "clock", width: 2, height: 1, needsIntegration: false }, - // Row 2: healthMonitoring + bookmarks = 12 - { kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true }, +const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): DemoWidget[] => [ + // Daily focus + { kind: "calendar", xOffset: 0, yOffset: 0, width: 2, height: 2, needsIntegration: true }, { - kind: "bookmarks", - width: 6, + kind: "weather", + xOffset: 2, + yOffset: 0, + width: 2, + height: 2, + needsIntegration: false, + options: { + location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 }, + hasForecast: false, + showHumidity: false, + showCurrentWindSpeed: false, + showCity: true, + animateIcons: false, + }, + }, + { + kind: "clock", + xOffset: 4, + yOffset: 0, + width: 1, + height: 2, + needsIntegration: false, + options: { customTitleToggle: false, showDate: false, customTimeFormat: "HH:mm" }, + }, + { + kind: "timer", + xOffset: 5, + yOffset: 0, + width: 2, + height: 1, + needsIntegration: false, + options: { + mode: "pomodoro", + focusMinutes: 25, + shortBreakMinutes: 5, + longBreakMinutes: 15, + sessionsBeforeLongBreak: 4, + autoStartBreaks: false, + autoStartFocus: false, + }, + }, + { + kind: "airQuality", + xOffset: 5, + yOffset: 1, + width: 2, + height: 1, + needsIntegration: false, + options: { + location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 }, + aqiStandard: "european", + showUv: false, + showPollutants: false, + showPollen: false, + }, + }, + { kind: "downloads", xOffset: 7, yOffset: 0, width: 5, height: 2, needsIntegration: true }, + + // Homarr workspace + { + kind: "notebook", + xOffset: 0, + yOffset: 2, + width: 5, height: 3, needsIntegration: false, - options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true }, + options: { + showToolbar: false, + allowReadOnlyCheck: true, + content: `

Quick runbook

Keep today close: signals first, controls beside them, distractions out.

  • Backups verified

  • Review active alerts

  • Connect your everyday services

  • Tune this board to your routine

Make it yours. Keep it useful.

`, + }, }, - // Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12 - { kind: "mediaServer", width: 3, height: 2, needsIntegration: true }, - { kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true }, - { kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true }, - // Row 4: dnsHoleControls + mediaReleases + notifications = 12 - { kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true }, - { kind: "mediaReleases", width: 4, height: 2, needsIntegration: true }, - { kind: "notifications", width: 6, height: 2, needsIntegration: true }, - // Row 5: requestList + requestStats + indexerManager = 12 - { kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true }, - { kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true }, - { kind: "indexerManager", width: 4, height: 2, needsIntegration: true }, - // Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12 - { kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true }, - { kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true }, + { kind: "beszelSystemGrid", xOffset: 5, yOffset: 2, width: 4, height: 3, needsIntegration: true }, + { kind: "assistant", xOffset: 9, yOffset: 2, width: 3, height: 3, needsIntegration: false }, + + // Around the centered network container + { kind: "mediaRequests-requestList", xOffset: 0, yOffset: 5, width: 3, height: 2, needsIntegration: true }, + { kind: "mediaMissing", xOffset: 9, yOffset: 5, width: 3, height: 2, needsIntegration: true }, + { kind: "mediaServer", xOffset: 0, yOffset: 7, width: 3, height: 2, needsIntegration: true }, + { kind: "mediaRequests-requestStats", xOffset: 9, yOffset: 7, width: 3, height: 2, needsIntegration: true }, { kind: "rssFeed", - width: 6, + xOffset: 0, + yOffset: 9, + width: 3, height: 2, needsIntegration: false, options: { feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"], - maximumAmountPosts: 20, + maximumAmountPosts: 12, textLinesClamp: 2, - hideDescription: false, + hideDescription: true, }, }, - // Row 7-8: app widgets (2x1 each, 6 per row = 12) + { kind: "indexerManager", xOffset: 9, yOffset: 9, width: 3, height: 2, needsIntegration: true }, + + // Infrastructure and activity + { kind: "dockerContainers", xOffset: 0, yOffset: 11, width: 4, height: 3, needsIntegration: false }, + { kind: "mediaReleases", xOffset: 4, yOffset: 11, width: 4, height: 3, needsIntegration: true }, + + // Community Workshop + { + kind: "customApi", + xOffset: 8, + yOffset: 11, + width: 4, + height: 3, + needsIntegration: false, + options: { definitionId: customWidgetDefinitionId, refreshInterval: 300 }, + }, + + // Network stuff container + { + kind: "healthMonitoring", + section: "network", + xOffset: 0, + yOffset: 0, + width: 6, + height: 2, + needsIntegration: true, + }, + { + kind: "dnsHoleSummary", + section: "network", + xOffset: 0, + yOffset: 2, + width: 3, + height: 2, + needsIntegration: true, + options: { layout: "grid", usePiHoleColors: false }, + }, + { + kind: "beszelSystemStats", + section: "network", + xOffset: 3, + yOffset: 2, + width: 3, + height: 2, + needsIntegration: true, + }, + { kind: "notifications", section: "network", xOffset: 0, yOffset: 4, width: 2, height: 2, needsIntegration: true }, + { kind: "beszelAlerts", section: "network", xOffset: 2, yOffset: 4, width: 4, height: 2, needsIntegration: true }, + + // Right app rail ...appIds.map( - (appId): DemoWidget => ({ + (appId, index): DemoWidget => ({ kind: "app", - width: 2, + section: "right", + xOffset: 0, + yOffset: index, + width: 1, height: 1, needsIntegration: false, options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false }, }), ), - // Row 9: beszelSystemGrid + beszelAlerts = 12 - { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true }, - { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true }, - // Row 10: beszelSystemTable + beszelSystemStats = 12 - { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true }, - { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true }, - // Row 11: notebook + dockerContainers + weather = 12 - { kind: "notebook", width: 4, height: 4, needsIntegration: false }, - { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false }, - { kind: "weather", width: 2, height: 1, needsIntegration: false }, - // Row 12: assistant - { kind: "assistant", width: 3, height: 3, needsIntegration: false }, ]; const seedDemoUserAsync = async (db: Database) => { @@ -710,44 +810,128 @@ const seedDemoUserAsync = async (db: Database) => { }); } + const customWidgetDefinitionId = createId(); + const customWidgetDefinition = customWidgetDefinitionSchema.parse({ + $schema: "homarr-custom-widget-v2", + name: "Community Workshop", + description: "Live Custom Widget and Custom CSS submissions shared by the Homarr community.", + sources: { + default: { + name: "Homarr Workshop", + baseUrl: "https://v2.preview.homarr.dev", + networkScope: "public", + auth: "none", + }, + }, + requests: { + workshop: { + path: "/api/collections/workshop_listings/records", + query: { perPage: 50 }, + cacheSeconds: 300, + }, + }, + options: {}, + template: ` + + COMMUNITY WORKSHOPMade by Homarr users + {data.workshop?.totalItems ?? "—"} shared + + Build Custom Widgets or dashboard CSS, then share it with the community. + {status.workshop?.loading ? : status.workshop?.error ? {status.workshop.error} : (data.workshop?.items ?? []).length === 0 ? The first community submission could be yours. : {(data.workshop?.items ?? []).slice(0, 4).map(submission => {submission.title}@{submission.authorName ?? "community"}{submission.upvotes ?? 0} ↑)}{data.workshop?.totalItems > 4 ? and {data.workshop.totalItems - 4} more in the Workshop : null}} + Install Homarr now → +`, + }); + await db.insert(customWidgetDefinitions).values({ + id: customWidgetDefinitionId, + name: customWidgetDefinition.name, + description: customWidgetDefinition.description ?? null, + iconUrl: null, + sources: SuperJSON.stringify(customWidgetDefinition.sources), + requests: SuperJSON.stringify(customWidgetDefinition.requests), + options: SuperJSON.stringify(customWidgetDefinition.options), + template: customWidgetDefinition.template, + enabled: true, + creatorId: userId, + }); + const boardId = createId(); await db.insert(boards).values({ id: boardId, name: "default", isPublic: false, creatorId: userId, + pageTitle: "Homarr demo", + backgroundImageUrl: "/images/demo-dashboard-background.svg", + primaryColor: "#748FFC", + secondaryColor: "#3BC9DB", + opacity: 90, + itemRadius: "xl", }); - const sectionId = createId(); + const mainSectionId = createId(); await db.insert(sections).values({ - id: sectionId, + id: mainSectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId, }); + const rightSectionId = createId(); + await db.insert(sections).values({ + id: rightSectionId, + kind: "empty", + xOffset: 1, + yOffset: 0, + boardId, + }); + + const networkSectionId = createId(); + await db.insert(sections).values({ + id: networkSectionId, + kind: "container", + boardId, + options: SuperJSON.stringify({ + title: "Network stuff", + customCssClasses: [], + borderColor: "", + showLabel: true, + collapsible: false, + showOpenAll: false, + scrollable: false, + }), + }); + const layoutId = createId(); await db.insert(layouts).values({ id: layoutId, name: "Base", - columnCount: 12, + columnCount: 13, + rightGutterColumnCount: 1, breakpoint: 768, role: "base", boardId, }); - const demoWidgets = buildDemoWidgets(appIds); - let xOffset = 0; - let yOffset = 0; - let rowMaxHeight = 0; + await db.insert(sectionLayouts).values({ + sectionId: networkSectionId, + layoutId, + parentSectionId: mainSectionId, + xOffset: 3, + yOffset: 5, + width: 6, + height: 6, + }); + + const demoWidgets = buildDemoWidgets(appIds, customWidgetDefinitionId); for (const widget of demoWidgets) { - if (xOffset + widget.width > 12) { - xOffset = 0; - yOffset += rowMaxHeight; - rowMaxHeight = 0; + let sectionId = mainSectionId; + if (widget.section === "right") { + sectionId = rightSectionId; + } else if (widget.section === "network") { + sectionId = networkSectionId; } - rowMaxHeight = Math.max(rowMaxHeight, widget.height); + const itemId = createId(); await db.insert(items).values({ id: itemId, @@ -759,8 +943,8 @@ const seedDemoUserAsync = async (db: Database) => { itemId, sectionId, layoutId, - xOffset, - yOffset, + xOffset: widget.xOffset, + yOffset: widget.yOffset, width: widget.width, height: widget.height, }); @@ -770,7 +954,6 @@ const seedDemoUserAsync = async (db: Database) => { integrationId, }); } - xOffset += widget.width; } await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId)); diff --git a/packages/db/test/sqlite-migration.spec.ts b/packages/db/test/sqlite-migration.spec.ts index ff21bbd9b..779f887f2 100644 --- a/packages/db/test/sqlite-migration.spec.ts +++ b/packages/db/test/sqlite-migration.spec.ts @@ -45,6 +45,196 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async () connection.close(); }); +test("SQLite migrations seed the redesigned demo dashboard", async () => { + const previousDemoMode = process.env.DEMO_MODE; + process.env.DEMO_MODE = "true"; + const connection = new BetterSqlite3(":memory:"); + const database = drizzle(connection, { schema: sqliteSchema, casing: DB_CASING }); + + try { + migrate(database, { migrationsFolder: path.join(__dirname, "..", "migrations", "sqlite") }); + await seedDataAsync(database as unknown as Database); + + const demoUser = await database.query.users.findFirst({ + where: (table, { eq }) => eq(table.name, "demo"), + }); + if (!demoUser?.homeBoardId) throw new Error("Demo user or home board was not seeded"); + + const board = await database.query.boards.findFirst({ + where: (table, { eq }) => eq(table.id, demoUser.homeBoardId ?? ""), + with: { + layouts: true, + sections: { with: { collapseStates: true, layouts: true } }, + items: { with: { layouts: true } }, + }, + }); + if (!board) throw new Error("Demo board was not seeded"); + + expect(board).toMatchObject({ + pageTitle: "Homarr demo", + backgroundImageUrl: "/images/demo-dashboard-background.svg", + }); + expect(board.layouts).toHaveLength(2); + expect(board.layouts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ columnCount: 13, rightGutterColumnCount: 1, role: "base" }), + expect.objectContaining({ columnCount: 3, rightGutterColumnCount: 0, role: "mobile" }), + ]), + ); + const baseLayout = board.layouts.find((layout) => layout.role === "base"); + if (!baseLayout) throw new Error("Demo base layout was not seeded"); + + const mainSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 0); + const rightSection = board.sections.find((section) => section.kind === "empty" && section.xOffset === 1); + const networkSection = board.sections.find((section) => section.kind === "container"); + if (!mainSection || !rightSection || !networkSection) throw new Error("Demo board sections were not seeded"); + + const networkLayout = networkSection.layouts.find((layout) => layout.layoutId === baseLayout.id); + expect(networkLayout).toMatchObject({ + parentSectionId: mainSection.id, + xOffset: 3, + yOffset: 5, + width: 6, + height: 6, + }); + if (!networkSection.options) throw new Error("Demo network section options were not seeded"); + expect(SuperJSON.parse<{ title: string; collapsible: boolean }>(networkSection.options)).toMatchObject({ + title: "Network stuff", + collapsible: false, + }); + expect(networkSection.collapseStates).toEqual([]); + + const itemByKind = (kind: string) => board.items.find((item) => item.kind === kind); + const expectItemLayout = ( + kind: string, + xOffset: number, + yOffset: number, + width: number, + height: number, + sectionId = mainSection.id, + ) => { + const layout = itemByKind(kind)?.layouts.find((candidate) => candidate.layoutId === baseLayout.id); + expect(layout).toMatchObject({ sectionId, xOffset, yOffset, width, height }); + }; + expectItemLayout("calendar", 0, 0, 2, 2); + expectItemLayout("weather", 2, 0, 2, 2); + expectItemLayout("clock", 4, 0, 1, 2); + expectItemLayout("timer", 5, 0, 2, 1); + expectItemLayout("airQuality", 5, 1, 2, 1); + expectItemLayout("downloads", 7, 0, 5, 2); + expectItemLayout("notebook", 0, 2, 5, 3); + expectItemLayout("beszelSystemGrid", 5, 2, 4, 3); + expectItemLayout("assistant", 9, 2, 3, 3); + expectItemLayout("mediaRequests-requestList", 0, 5, 3, 2); + expectItemLayout("mediaMissing", 9, 5, 3, 2); + expectItemLayout("mediaServer", 0, 7, 3, 2); + expectItemLayout("mediaRequests-requestStats", 9, 7, 3, 2); + expectItemLayout("rssFeed", 0, 9, 3, 2); + expectItemLayout("indexerManager", 9, 9, 3, 2); + expectItemLayout("dockerContainers", 0, 11, 4, 3); + expectItemLayout("mediaReleases", 4, 11, 4, 3); + expectItemLayout("customApi", 8, 11, 4, 3); + + expectItemLayout("healthMonitoring", 0, 0, 6, 2, networkSection.id); + expectItemLayout("dnsHoleSummary", 0, 2, 3, 2, networkSection.id); + expectItemLayout("beszelSystemStats", 3, 2, 3, 2, networkSection.id); + expectItemLayout("notifications", 0, 4, 2, 2, networkSection.id); + expectItemLayout("beszelAlerts", 2, 4, 4, 2, networkSection.id); + + for (const kind of ["weather", "airQuality"]) { + const item = itemByKind(kind); + if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`); + expect(SuperJSON.parse<{ location: { name: string } }>(item.options).location.name).toBe("Paris"); + } + + const expectFilledGrid = ( + placements: { xOffset: number; yOffset: number; width: number; height: number }[], + columnCount: number, + ) => { + const rowCount = Math.max(...placements.map((placement) => placement.yOffset + placement.height)); + const cells = Array.from({ length: rowCount }, () => Array(columnCount).fill(0)); + for (const placement of placements) { + for (let y = placement.yOffset; y < placement.yOffset + placement.height; y += 1) { + for (let x = placement.xOffset; x < placement.xOffset + placement.width; x += 1) { + const row = cells[y]; + if (!row) throw new Error("Demo placement exceeds the grid height"); + row[x] = (row[x] ?? 0) + 1; + } + } + } + expect(cells.every((row) => row.length === columnCount && row.every((cell) => cell === 1))).toBe(true); + }; + + const mainItemLayouts = board.items.flatMap((item) => + item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === mainSection.id), + ); + if (!networkLayout) throw new Error("Demo network section layout was not seeded"); + expectFilledGrid([...mainItemLayouts, networkLayout], 12); + + const rightRailItems = board.items.filter((item) => + item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id), + ); + expect(rightRailItems).toHaveLength(12); + const rightRailLayouts = rightRailItems.flatMap((item) => + item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === rightSection.id), + ); + expect( + rightRailItems.every((item) => { + const layout = item.layouts.find((candidate) => candidate.layoutId === baseLayout.id); + return item.kind === "app" && layout?.width === 1 && layout.height === 1; + }), + ).toBe(true); + expect( + rightRailLayouts + .toSorted((first, second) => first.yOffset - second.yOffset) + .map(({ sectionId, xOffset, yOffset, width, height }) => ({ + sectionId, + xOffset, + yOffset, + width, + height, + })), + ).toEqual( + Array.from({ length: 12 }, (_, yOffset) => ({ + sectionId: rightSection.id, + xOffset: 0, + yOffset, + width: 1, + height: 1, + })), + ); + expectFilledGrid(rightRailLayouts, 1); + + const networkItems = board.items.filter((item) => + item.layouts.some((layout) => layout.layoutId === baseLayout.id && layout.sectionId === networkSection.id), + ); + expect(networkItems.map((item) => item.kind).toSorted()).toEqual( + ["beszelAlerts", "beszelSystemStats", "dnsHoleSummary", "healthMonitoring", "notifications"].toSorted(), + ); + const networkItemLayouts = networkItems.flatMap((item) => + item.layouts.filter((layout) => layout.layoutId === baseLayout.id && layout.sectionId === networkSection.id), + ); + expectFilledGrid(networkItemLayouts, 6); + + const workshopDefinition = await database.query.customWidgetDefinitions.findFirst({ + where: (table, { eq }) => eq(table.creatorId, demoUser.id), + }); + if (!workshopDefinition) throw new Error("Demo Workshop definition was not seeded"); + expect(workshopDefinition.name).toBe("Community Workshop"); + expect(SuperJSON.parse>(workshopDefinition.sources).default?.baseUrl).toBe( + "https://v2.preview.homarr.dev", + ); + + const workshopItem = itemByKind("customApi"); + if (!workshopItem) throw new Error("Demo Workshop item was not seeded"); + expect(SuperJSON.parse<{ definitionId: string }>(workshopItem.options).definitionId).toBe(workshopDefinition.id); + } finally { + connection.close(); + if (previousDemoMode === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = previousDemoMode; + } +}); + test("Custom Widget v2 migration preserves v1 data and board references", () => { const connection = new BetterSqlite3(":memory:"); const legacyPlacementOptions = SuperJSON.stringify({ definitionId: "legacy-weather", refreshInterval: 30 });