Compare commits

..
Author SHA1 Message Date
ajnart bfb834e087 revert: drop Umami concurrency change 2026-08-22 09:06:11 +02:00
ajnart 7a56a7d74f refactor: minimize review fixes 2026-08-22 09:04:17 +02:00
ajnart 396b78aa3e revert: keep only minimal review fixes 2026-08-22 09:00:57 +02:00
ajnart caf1f6621a Merge remote-tracking branch 'origin/release/v2' into feat/fix-v2-stack-review-findings 2026-08-22 08:57:25 +02:00
ajnart dfa32c6add revert: trim optional review fixes 2026-08-22 08:37:13 +02:00
ajnart 5522378ef2 fix: restore fast gate after release sync 2026-08-21 19:14:12 +02:00
ajnart e1775e524b Merge origin/release/v2 into feat/fix-v2-stack-review-findings 2026-08-21 19:08:02 +02:00
ajnart 1b544f599c fix: address review findings in the v2 stack fix PR
Board scaling: no sideways scrolling
- Revert the pannable narrow canvas. The canvas fits the viewport again and
  `.viewport` goes back to `overflow: clip`, so a board can never widen the
  document or scroll horizontally.
- Fix the unreadable-narrow-board regression at the real cause instead: cap the
  inverse UI compensation at 2x (50% canvas scale). Down to 50% text and
  controls keep their physical size; below it the board zooms out as a whole, so
  a 200x200 logical tile hosts at most 28px logical text rather than 96px and
  content stops overlapping its neighbours.

Touch editing
- Add `touch-action: none` to `.board-grid-entry`, the dnd-kit draggable and its
  own drag handle. The 200ms touch activation delay is useless if the browser
  has already handed the gesture to page panning and emitted `pointercancel`.
  Only edit mode renders this element.

Custom widgets
- Memoize the merged `data`/`status`/`options` passed to `CustomJsxRenderer`.
  They were rebuilt every render, so the compiled-template memo never hit and
  the new reference-identity boundary key changed on every render, resetting the
  error boundary and forcing an extra reconciliation of the widget subtree.
- Keep the cache key when the shared Redis cache is unavailable so the
  process-local cache still absorbs polling instead of sending every widget
  refresh upstream.
- Reclaim the shared cache's LRU/size/byte bookkeeping for TTL-expired entries
  and touch the LRU in the same script, so expired ghosts stop consuming the
  64 MiB budget and a read costs one round trip instead of two.

Forwarded IP
- Restore `ipAddressFromHeaders` to the full forwarding chain and add
  `trustedIpAddressFromHeaders` for the last hop. Repointing the shared helper
  had collapsed MCP failed-auth rate limiting into a single bucket for every
  client behind a reverse proxy and replaced the client IP with the proxy IP in
  API-key auth logs.

Runtime and restore
- Make the new /appdata ownership migration best-effort. Under `set -e` a mount
  that rejects chown (CIFS/SMB, fixed-uid NFS) turned into a container that
  refuses to start, and /appdata was never chowned before.
- Report malformed metadata.json as such instead of blaming the archive.
2026-08-21 01:40:46 +02:00
ajnart 496fa60b5f Merge remote-tracking branch 'origin/feat/fix-v2-stack-review-findings' into feat/fix-v2-stack-review-findings
# Conflicts:
#	apps/docs/docs/management/assistant.mdx
2026-08-20 16:35:25 +02:00
ajnart 5fb44a49e3 Merge remote-tracking branch 'origin/release/v2' into feat/fix-v2-stack-review-findings
# Conflicts:
#	apps/docs/docs/management/assistant.mdx
2026-08-20 16:34:48 +02:00
Thomas 4faf81844a Merge branch 'release/v2' into feat/fix-v2-stack-review-findings 2026-08-20 16:26:51 +02:00
ajnart 19274cba54 fix: preserve restore and setup errors 2026-08-20 15:41:48 +02:00
ajnart cf624e2f23 fix: address coderabbit review feedback 2026-08-20 15:20:21 +02:00
ajnart 17aef0588f fix: keep custom widget limits client safe 2026-08-20 14:59:18 +02:00
ajnart ca20431d84 fix: address final review findings 2026-08-20 14:48:02 +02:00
ajnart c732c5045d docs: align v2 fix behavior 2026-08-20 13:08:54 +02:00
ajnart 263702210a fix: address v2 stack review findings 2026-08-20 13:08:54 +02:00
16 changed files with 164 additions and 574 deletions

No files matched your search

@@ -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, ...).
- **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.
- **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.
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, and the user menu.
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
### Tour controls
@@ -27,10 +27,6 @@ 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.
@@ -3,9 +3,18 @@
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, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
import {
IconDeviceFloppy,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconReplace,
IconSettings,
IconX,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -16,6 +25,8 @@ 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";
@@ -45,7 +56,9 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) return null;
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
return (
<>
@@ -60,6 +73,8 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
</HeaderButton>
</TourTarget>
)}
<SelectBoardsMenu />
</>
);
};
@@ -220,6 +235,47 @@ 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 (
<TourTarget id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("action.switch")}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const tBoard = useI18n("board");
@@ -1,21 +0,0 @@
.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);
}
@@ -1,331 +0,0 @@
"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<Array<HTMLAnchorElement | null>>([]);
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<HTMLDivElement>) => {
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 })}
<Modal
opened={isOpen}
onClose={closeSwitcher}
title={
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" radius="md">
<IconReplace size={18} stroke={1.5} />
</ThemeIcon>
<Text fw={700}>{t("title")}</Text>
</Group>
}
size={1280}
yOffset={32}
overlayProps={{ backgroundOpacity: 0.18, blur: 2 }}
transitionProps={{ transition: "fade", duration: 100, timingFunction: "ease" }}
>
<Stack
data-autofocus
tabIndex={-1}
gap="md"
onKeyDown={handleModalKeyDown}
className={classes.content}
aria-describedby="board-switcher-instructions"
>
<VisuallyHidden id="board-switcher-instructions">{t("keyboard.instructions")}</VisuallyHidden>
<ScrollArea.Autosize mah={640} type="auto" offsetScrollbars>
{results}
</ScrollArea.Autosize>
<Group justify={currentBoard ? "space-between" : "flex-end"} gap="xs" visibleFrom="sm">
{currentBoard && (
<Text size="xs" c="dimmed">
{t("currentHidden", { name: currentBoard.name })}
</Text>
)}
<Group gap="xs">
<Kbd size="xs">
<IconArrowRight size={12} />
</Kbd>
<Kbd size="xs">
<IconArrowDown size={12} />
</Kbd>
<Text size="xs" c="dimmed">
{t("keyboard.navigate")}
</Text>
<Kbd size="xs">Enter</Kbd>
<Text size="xs" c="dimmed">
{t("keyboard.open")}
</Text>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};
interface BoardSwitcherResultsProps {
availableBoardCount: number;
filteredBoards: RouterOutputs["board"]["getManageOverview"];
isError: boolean;
isPending: boolean;
search: string;
t: ReturnType<typeof useI18n<"board.action.switcher">>;
activeIndex: number;
closeSwitcher: () => void;
setActiveIndex: (index: number) => void;
optionRefs: RefObject<Array<HTMLAnchorElement | null>>;
columnCount: number;
}
const getBoardSwitcherResults = ({
availableBoardCount,
filteredBoards,
isError,
isPending,
search,
t,
activeIndex,
closeSwitcher,
setActiveIndex,
optionRefs,
columnCount,
}: BoardSwitcherResultsProps) => {
if (isPending) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
if (isError) {
return (
<Center py="xl">
<Text c="dimmed" size="sm">
{t("error")}
</Text>
</Center>
);
}
if (availableBoardCount === 0) {
return (
<Center py="xl">
<Stack align="center" gap={4}>
<ThemeIcon variant="light" color="gray" size="xl" radius="xl">
<IconLayoutBoard stroke={1.5} />
</ThemeIcon>
<Text fw={600}>{t("empty.title")}</Text>
<Text c="dimmed" size="sm" ta="center">
{t("empty.description")}
</Text>
</Stack>
</Center>
);
}
if (filteredBoards.length === 0) {
return (
<Center py="xl">
<Text c="dimmed" size="sm">
{t("search.empty", { search })}
</Text>
</Center>
);
}
return (
<SimpleGrid id="board-switcher-results" aria-label={t("results")} cols={columnCount} spacing="md" p={4}>
{filteredBoards.map((board, index) => (
<UnstyledButton
key={board.id}
ref={(node) => {
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)}
>
<Card padding={0} withBorder radius="md" className={classes.card}>
<Card.Section>
<BoardLayoutThumbnail
preview={board.preview}
label={t("preview", { name: board.name, count: String(board.preview?.items.length ?? 0) })}
/>
</Card.Section>
<Card.Section withBorder p="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" radius="md" size="md">
<IconLayoutBoard size={16} stroke={1.5} />
</ThemeIcon>
<Text fw={650} truncate>
{board.name}
</Text>
</Group>
</Card.Section>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
);
};
@@ -46,11 +46,6 @@ export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps
description={tSection("option.showOpenAll.description")}
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<Switch
label={tSection("option.scrollable.label")}
description={tSection("option.scrollable.description")}
{...form.getInputProps("scrollable", { type: "checkbox" })}
/>
<TextMultiSelect
label={tSection("container.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
@@ -296,21 +296,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
top: -0.625rem;
left: -0.625rem;
cursor: nwse-resize;
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
so it doesn't sit on top of the container menu button anchored in this same corner. */
clip-path: polygon(
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
4px 4px,
4px 40px,
40px 40px,
40px 4px,
4px 4px
);
}
@media (prefers-reduced-motion: reduce) {
@@ -91,17 +91,7 @@ export const GridPreviewLayer = ({
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
// This grows the viewport to match the inner grid while a drag needs extra room to show a
// drop target (maxRowCount === null - a "main"/rail section without a fixed cap). A capped
// section (e.g. a scrollable container, maxRowCount !== null) must never have this set: it
// isn't just a temporary default here, this effect reruns on every board mutation - so a
// stray value would silently blow the cap open for the rest of the session, well after any
// drag ended, since nothing else would ever clear it back out.
if (maxRowCount === null) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
} else {
viewport.style.removeProperty("--board-grid-drag-height");
}
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
}
}, [
entryElements,
@@ -3,28 +3,6 @@
overflow: visible;
}
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
that flips which of these two rules the cascade picks, silently un-capping the container even
though its height (set inline, see section-grid.tsx) stays correct throughout. */
.viewport.scrollableViewport {
overflow-x: hidden;
overflow-y: auto;
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
while growing during a drag happening anywhere else on the board - a nested scrollable
container sits inside that section's DOM tree, so the stray value cascades straight
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in
section-grid.tsx, which only kicks in when the property is entirely unset, not merely
inherited. Reset it here so this element can never inherit a stray value from anywhere
above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
--board-grid-drag-height: initial;
}
.staticGrid,
.editorGrid {
position: relative;
@@ -130,13 +130,8 @@ export const SectionGrid = ({
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const viewportHeight = getLogicalTrackSize(viewportRowCount);
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -195,18 +190,6 @@ export const SectionGrid = ({
section,
]);
const viewportRef = useRef<HTMLDivElement>(null);
const previousItemIdsRef = useRef<Set<string> | null>(null);
useEffect(() => {
const currentIds = new Set([...items, ...innerSections].map((item) => item.id));
const previousIds = previousItemIdsRef.current;
const hasNewItem = previousIds !== null && [...currentIds].some((id) => !previousIds.has(id));
previousItemIdsRef.current = currentIds;
if (hasNewItem && isScrollableContainer) {
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}
}, [innerSections, isScrollableContainer, items]);
return (
<SectionProvider
value={{
@@ -223,20 +206,18 @@ export const SectionGrid = ({
}}
>
<Box
ref={viewportRef}
{...canvasAttributes}
className={combineClasses(classes.viewport, isScrollableContainer && classes.scrollableViewport, className)}
className={combineClasses(classes.viewport, className)}
style={
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}
data-scrollable={isScrollableContainer ? "true" : undefined}
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
@@ -315,11 +296,6 @@ const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, la
if (visiting.has(sectionId)) return { width: 1, height: 1 };
visiting.add(sectionId);
const section = board.sections.find((candidate) => candidate.id === sectionId);
// A scrollable container isn't forced to grow with its content - it scrolls internally instead,
// so it shouldn't have a content-derived height floor imposed by its parent's grid.
const isScrollable = section?.kind === "container" && section.options.scrollable;
const itemBounds = directItemsBySectionId.get(sectionId) ?? [];
const sectionBounds = (directSectionsBySectionId.get(sectionId) ?? []).map(({ id, placement }) => {
const minimum = resolve(id);
@@ -332,7 +308,7 @@ const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, la
const children = [...itemBounds, ...sectionBounds];
const minimum = {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
height: isScrollable ? 1 : Math.max(1, ...children.map((child) => child.yOffset + child.height)),
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
};
visiting.delete(sectionId);
minimumBySectionId.set(sectionId, minimum);
@@ -66,6 +66,16 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => {
/>
),
},
{
id: "board-switcher",
title: t("switcher.title"),
content: (
<TourStepContent
description={t("switcher.description")}
documentationHref={createDocumentationLink("/docs/management/boards")}
/>
),
},
{
id: "board-user-menu",
title: t("userMenu.title"),
+89 -105
View File
@@ -8,7 +8,6 @@ import {
IconHome,
IconLogin,
IconLogout,
IconReplace,
IconSettings,
IconRobot,
IconTool,
@@ -23,7 +22,6 @@ 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";
@@ -43,7 +41,6 @@ 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();
@@ -59,111 +56,98 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
}, [logoutUrl]);
return (
<BoardSwitcher>
{({ open, preload, hotkey }) => (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Item
leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(hotkey, t("modifier"))}</Kbd>}
onClick={open}
onFocus={preload}
onPointerEnter={preload}
>
{tBoard("action.switch")}
</Menu.Item>
<Menu.Divider />
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")}
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentColorSchemeCombobox />
</Menu.Item>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
{Boolean(session.data) && (
<>
{assistant?.enabled && (
<Menu.Item
leftSection={
assistant.isRunning ? <Loader type="bars" color="red" size="xs" /> : <IconRobot size="1rem" />
}
rightSection={
assistant.unreadCount > 0 ? (
<Badge size="sm" variant="filled" color="red" circle>
{assistant.unreadCount > 99 ? "99+" : assistant.unreadCount}
</Badge>
) : assistant.isRunning ? (
<Text size="xs" c="dimmed">
{t("assistantThinking")}
</Text>
) : (
assistant.unreadCount
<Kbd size="xs">{formatHotkeyLabel(hotkeys.openAssistant, t("modifier"))}</Kbd>
)
) : undefined
}
}
onClick={assistant.open}
>
{t("assistant")}
</Menu.Item>
)}
<Menu.Item
component={Link}
href={`/manage/users/${session.data?.user.id}/general`}
leftSection={<IconSettings size="1rem" />}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
)}
</BoardSwitcher>
{t("preferences")}
</Menu.Item>
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{invariantTechnicalLabels.docker}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
{session.status === "authenticated" ? (
<Menu.Item onClick={handleSignout} leftSection={<IconLogout size="1rem" />} color="red">
{t("logout")}
</Menu.Item>
) : (
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}
</Menu.Dropdown>
<Menu.Target>
<Indicator
inline
disabled={!assistant?.isRunning && !assistant?.unreadCount}
color="red"
size={20}
offset={4}
label={
assistant?.isRunning ? (
<Loader type="bars" color="white" size={10} />
) : assistant?.unreadCount ? (
assistant.unreadCount > 99 ? (
"99+"
) : (
assistant.unreadCount
)
) : undefined
}
>
{children}
</Indicator>
</Menu.Target>
</Menu>
);
};
@@ -1396,7 +1396,6 @@ describe("saveBoard should save full board", () => {
showLabel: false,
collapsible: false,
showOpenAll: false,
scrollable: false,
});
expect(
await db.query.sectionLayouts.findFirst({
-23
View File
@@ -1943,10 +1943,6 @@
"showOpenAll": {
"label": "Show open-all action",
"description": "Show an action that opens every app in this container in new tabs."
},
"scrollable": {
"label": "Scrollable",
"description": "Keep this container at its set size and scroll its contents instead of growing to fit them."
}
},
"action": {
@@ -5130,25 +5126,6 @@
"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…",
-2
View File
@@ -75,7 +75,6 @@ describe("section behavior validation", () => {
showLabel: false,
collapsible: true,
showOpenAll: true,
scrollable: true,
},
layouts: [],
});
@@ -89,7 +88,6 @@ describe("section behavior validation", () => {
showLabel: false,
collapsible: true,
showOpenAll: true,
scrollable: true,
},
});
});
-2
View File
@@ -61,7 +61,6 @@ export const containerSectionOptionsDefaults = {
showLabel: true,
collapsible: false,
showOpenAll: false,
scrollable: false,
} as const;
export const containerSectionOptionsSchema = z
@@ -72,7 +71,6 @@ export const containerSectionOptionsSchema = z
showLabel: z.boolean().default(containerSectionOptionsDefaults.showLabel),
collapsible: z.boolean().default(containerSectionOptionsDefaults.collapsible),
showOpenAll: z.boolean().default(containerSectionOptionsDefaults.showOpenAll),
scrollable: z.boolean().default(containerSectionOptionsDefaults.scrollable),
})
.default(containerSectionOptionsDefaults);