feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong committed 2026-08-01 21:14:33 +02:00
1 parent fcc5130068
commit d421046d15
286 files changed
+9664 -3282

No files matched your search

@@ -0,0 +1,132 @@
.focusScope {
height: 100%;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 198;
background: rgba(0, 0, 0, 0.64);
backdrop-filter: blur(6px) saturate(0.75);
touch-action: none;
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.surface {
position: fixed;
z-index: 199;
pointer-events: auto;
overflow: hidden;
background: var(--mantine-color-body);
box-shadow: var(--mantine-shadow-lg);
overscroll-behavior: contain;
animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
}
.backdropClosing {
animation: focus-backdrop-out 180ms ease-in forwards;
}
.surfaceClosing {
animation: focus-surface-out 180ms ease-in forwards;
}
.sourcePlaceholder {
pointer-events: none;
}
.surfaceHost {
width: 100%;
height: 100%;
}
.surfaceHostAdvanced {
position: fixed;
inset: 0;
z-index: 199;
width: auto;
height: auto;
pointer-events: none;
}
.surfaceControls {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
pointer-events: auto;
}
.pinButton {
position: absolute;
top: 8px;
right: 52px;
z-index: 2;
pointer-events: auto;
}
.touchButton {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 2;
display: none;
pointer-events: auto;
}
@media (pointer: coarse) {
.touchButton {
display: inline-flex;
}
}
@keyframes focus-backdrop-in {
from {
opacity: 0;
}
}
@keyframes focus-surface-in {
from {
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
scale(var(--focus-scale-x), var(--focus-scale-y));
opacity: 0.72;
}
}
@keyframes focus-backdrop-out {
to {
opacity: 0;
}
}
@keyframes focus-surface-out {
to {
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
scale(var(--focus-scale-x), var(--focus-scale-y));
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
animation-duration: 1ms;
}
}
@media (forced-colors: active) {
.backdrop {
backdrop-filter: none;
background: Canvas;
opacity: 0.8;
}
}
@@ -0,0 +1,213 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { Box, Portal, RemoveScroll } from "@mantine/core";
import { useEditMode } from "@homarr/boards/edit-mode";
import classes from "./advanced-focus.module.css";
import { isEscapeOwnedByNestedOverlay } from "./escape";
import type { FocusRect } from "./geometry";
interface ActiveFocus {
itemId: string;
source: HTMLElement;
sourceRect: FocusRect;
pinned: boolean;
autofocusClose: boolean;
phase: "visible" | "closing";
}
interface AdvancedFocusContextValue {
active: ActiveFocus | null;
open: (itemId: string, source: HTMLElement, pinned?: boolean) => void;
close: (restoreFocus?: boolean) => void;
dismiss: (itemId: string) => void;
pin: (autofocusClose?: boolean) => void;
hover: (itemId: string, source: HTMLElement) => void;
leave: (itemId: string) => void;
}
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
const HOVER_DELAY_MS = 150;
const CLOSE_DURATION_MS = 180;
const isEditableTarget = (target: EventTarget | null) =>
target instanceof HTMLElement &&
(target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable);
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode();
const [active, setActive] = useState<ActiveFocus | null>(null);
const activeRef = useRef<ActiveFocus | null>(null);
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
const shiftHeldRef = useRef(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelHoverTimer = useCallback(() => {
if (hoverTimerRef.current === null) return;
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}, []);
const updateActive = useCallback((next: ActiveFocus | null) => {
activeRef.current = next;
setActive(next);
}, []);
const cancelCloseTimer = useCallback(() => {
if (closeTimerRef.current === null) return;
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}, []);
const open = useCallback(
(itemId: string, source: HTMLElement, pinned = false) => {
if (isEditMode) return;
cancelHoverTimer();
cancelCloseTimer();
const rect = source.getBoundingClientRect();
updateActive({
itemId,
source,
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
pinned,
autofocusClose: pinned,
phase: "visible",
});
},
[cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive],
);
const close = useCallback(
(restoreFocus = true) => {
cancelHoverTimer();
const current = activeRef.current;
if (!current || current.phase === "closing") return;
updateActive({ ...current, phase: "closing" });
const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS;
closeTimerRef.current = setTimeout(() => {
closeTimerRef.current = null;
updateActive(null);
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
}, closeDuration);
},
[cancelHoverTimer, updateActive],
);
const startHoverTimer = useCallback(() => {
cancelHoverTimer();
const hovered = hoveredRef.current;
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS);
}, [cancelHoverTimer, open]);
const hover = useCallback(
(itemId: string, source: HTMLElement) => {
hoveredRef.current = { itemId, source };
startHoverTimer();
},
[startHoverTimer],
);
const leave = useCallback(
(itemId: string) => {
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
cancelHoverTimer();
},
[cancelHoverTimer],
);
const dismiss = useCallback(
(itemId: string) => {
if (activeRef.current?.itemId !== itemId) return;
cancelHoverTimer();
cancelCloseTimer();
updateActive(null);
},
[cancelCloseTimer, cancelHoverTimer, updateActive],
);
const pin = useCallback(
(autofocusClose = false) => {
const current = activeRef.current;
if (!current || current.pinned || current.phase === "closing") return;
updateActive({ ...current, pinned: true, autofocusClose });
},
[updateActive],
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && activeRef.current) {
const advancedSurface = document.querySelector(`.${classes.surface}`);
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
return;
event.preventDefault();
close();
return;
}
if (isEditMode) return;
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
if (activeRef.current?.pinned) return;
shiftHeldRef.current = true;
startHoverTimer();
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key !== "Shift") return;
shiftHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current && !activeRef.current.pinned) close(false);
};
const handleBlur = () => {
shiftHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current && !activeRef.current.pinned) close(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
cancelHoverTimer();
cancelCloseTimer();
};
}, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]);
useEffect(() => {
if (isEditMode && activeRef.current) close(false);
}, [close, isEditMode]);
const value = useMemo(
() => ({ active, open, close, dismiss, pin, hover, leave }),
[active, close, dismiss, hover, leave, open, pin],
);
return (
<AdvancedFocusContext.Provider value={value}>
<RemoveScroll enabled={active !== null} className={classes.focusScope}>
{children}
</RemoveScroll>
{active && (
<Portal>
<Box
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
onPointerDown={() => close()}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusContext.Provider>
);
};
export const useAdvancedFocus = () => {
const context = useContext(AdvancedFocusContext);
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
return context;
};
@@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";
import { isEscapeOwnedByNestedOverlay } from "./escape";
describe("isEscapeOwnedByNestedOverlay", () => {
test("allows Escape from the advanced surface itself", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const button = document.createElement("button");
surface.append(button);
expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false);
});
test("leaves Escape to a nested Mantine modal", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const modal = document.createElement("section");
modal.setAttribute("role", "dialog");
const input = document.createElement("input");
modal.append(input);
expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true);
});
test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const overlay = document.createElement("div");
overlay.setAttribute("role", role);
const target = document.createElement("button");
overlay.append(target);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
});
test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const target = document.createElement("button");
target.setAttribute("data-mantine-stop-propagation", "true");
surface.append(target);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
});
test("does not mistake the compact widget source for a nested overlay", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const source = document.createElement("button");
expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
});
});
@@ -0,0 +1,13 @@
const escapeOwnerSelector = [
'[data-mantine-stop-propagation="true"]',
'[role="dialog"]',
'[role="menu"]',
'[role="listbox"]',
].join(", ");
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
if (!(target instanceof Element)) return false;
const escapeOwner = target.closest(escapeOwnerSelector);
return escapeOwner !== null && escapeOwner !== advancedSurface;
};
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { getAdvancedFocusRect } from "./geometry";
describe("getAdvancedFocusRect", () => {
it("expands a widget and keeps it inside the viewport", () => {
expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({
left: 240,
top: 190,
width: 720,
height: 480,
});
});
it("uses the available viewport on small screens", () => {
expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({
left: 8,
top: 68,
width: 374,
height: 624,
});
});
it("keeps controls below the fixed board header on short screens", () => {
const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 });
expect(rect.top).toBeGreaterThanOrEqual(84);
expect(rect.top + rect.height).toBeLessThanOrEqual(744);
});
});
@@ -0,0 +1,26 @@
import { appShellHeaderHeight } from "../../layout/constants";
export interface FocusRect {
left: number;
top: number;
width: number;
height: number;
}
export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
const margin = viewport.width < 640 ? 8 : 24;
const availableWidth = Math.max(0, viewport.width - margin * 2);
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4)));
const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4)));
return {
left: Math.max(margin, (viewport.width - width) / 2),
top: Math.max(
appShellHeaderHeight + margin,
appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2,
),
width,
height,
};
};
@@ -3,35 +3,48 @@ import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
import { getFirstEmptyPosition, getNearestEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
export interface BoardPlacementHint {
sectionId: string;
layoutId: string;
xOffset: number;
yOffset: number;
}
export interface CreateItemInput {
id?: string;
kind: WidgetKind;
options?: Record<string, unknown>;
integrationIds?: string[];
advancedOptions?: BoardItemAdvancedOptions;
placement?: BoardPlacementHint;
}
export const createItemCallback =
({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
({ id, kind, options = {}, integrationIds = [], advancedOptions, placement }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
if (!firstSection && !placement) return previous;
const defaultSectionId = firstSection?.id ?? placement?.sectionId;
if (!defaultSectionId) return previous;
const widget = {
id: id ?? createId(),
kind,
options,
layouts: createItemLayouts(previous, firstSection, kind),
layouts: createItemLayouts(previous, defaultSectionId, kind, placement),
integrationIds,
advancedOptions: {
advancedOptions: advancedOptions ?? {
title: null,
customCssClasses: [],
borderColor: "",
@@ -53,16 +66,31 @@ const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
const createItemLayouts = (
board: Board,
defaultSectionId: string,
kind: WidgetKind,
placement?: BoardPlacementHint,
): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const targetSectionId = placement?.layoutId === layoutId ? placement.sectionId : defaultSectionId;
const elements = getSectionElements(board, { sectionId: targetSectionId, layoutId });
const targetSection = board.sections.find((section) => section.id === targetSectionId);
const dynamicLayout =
targetSection?.kind === "dynamic"
? targetSection.layouts.find((layout) => layout.layoutId === layoutId)
: undefined;
const columnCount = dynamicLayout?.width ?? boardLayout?.columnCount ?? 1;
const rowCount = dynamicLayout?.height ?? 9999;
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
? placement?.layoutId === layoutId
? getNearestEmptyPosition(elements, columnCount, rowCount, placement, itemSize)
: getFirstEmptyPosition(elements, columnCount, rowCount, itemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -73,7 +101,7 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
width: itemSize.width,
height: itemSize.height,
...emptyPosition,
sectionId: currentSection.id,
sectionId: targetSectionId,
layoutId,
};
});
@@ -7,19 +7,56 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
element.yOffset + element.height > yOffset &&
element.xOffset < xOffset + size.width &&
element.xOffset + element.width > xOffset,
);
if (!isOccupied) {
for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
if (isPositionEmpty(elements, { xOffset, yOffset }, size)) {
return { xOffset, yOffset };
}
}
}
return undefined;
};
export const getNearestEmptyPosition = (
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
columnCount: number,
rowCount: number,
preferred: { xOffset: number; yOffset: number },
size: { width: number; height: number },
) => {
const maxX = columnCount - size.width;
const maxY = rowCount - size.height;
if (maxX < 0 || maxY < 0) return undefined;
const clamped = {
xOffset: Math.min(Math.max(0, preferred.xOffset), maxX),
yOffset: Math.min(Math.max(0, preferred.yOffset), maxY),
};
if (isPositionEmpty(elements, clamped, size)) return clamped;
const lastOccupiedRow = elements.reduce((maximum, element) => Math.max(maximum, element.yOffset + element.height), 0);
const searchMaxY = Math.min(maxY, Math.max(clamped.yOffset + 20, lastOccupiedRow + size.height));
let best: { xOffset: number; yOffset: number; distance: number } | undefined;
for (let yOffset = 0; yOffset <= searchMaxY; yOffset++) {
for (let xOffset = 0; xOffset <= maxX; xOffset++) {
if (!isPositionEmpty(elements, { xOffset, yOffset }, size)) continue;
const distance = Math.abs(xOffset - clamped.xOffset) + Math.abs(yOffset - clamped.yOffset);
if (!best || distance < best.distance) best = { xOffset, yOffset, distance };
}
}
return best && { xOffset: best.xOffset, yOffset: best.yOffset };
};
const isPositionEmpty = (
elements: Pick<SectionItem, "yOffset" | "xOffset" | "width" | "height">[],
position: { xOffset: number; yOffset: number },
size: { width: number; height: number },
) =>
!elements.some(
(element) =>
element.yOffset < position.yOffset + size.height &&
element.yOffset + element.height > position.yOffset &&
element.xOffset < position.xOffset + size.width &&
element.xOffset + element.width > position.xOffset,
);
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -108,4 +112,58 @@ describe("item actions create-item", () => {
{ height: 1, width: 1 },
);
});
test("uses the clicked section and nearest cell for the active layout", () => {
const activeLayoutId = "desktop";
const secondaryLayoutId = "mobile";
const rootSectionId = "root";
const targetSectionId = "target";
const activeLayout = new LayoutMockBuilder({ id: activeLayoutId, columnCount: 12 }).build();
const secondaryLayout = new LayoutMockBuilder({ id: secondaryLayoutId, columnCount: 4 }).build();
const targetSection = new DynamicSectionMockBuilder({ id: targetSectionId })
.addLayout({
layoutId: activeLayoutId,
parentSectionId: rootSectionId,
width: 4,
height: 3,
})
.build();
const board = new BoardMockBuilder()
.addLayout(activeLayout)
.addLayout(secondaryLayout)
.addEmptySection({ id: rootSectionId, yOffset: 0 })
.addSection(targetSection)
.build();
vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([activeLayoutId, secondaryLayoutId]);
const result = createItemCallback({
kind: "clock",
placement: {
sectionId: targetSectionId,
layoutId: activeLayoutId,
xOffset: 2,
yOffset: 1,
},
})(board);
expect(result.items.at(0)?.layouts).toEqual([
{
layoutId: activeLayoutId,
sectionId: targetSectionId,
xOffset: 2,
yOffset: 1,
width: 1,
height: 1,
},
{
layoutId: secondaryLayoutId,
sectionId: rootSectionId,
xOffset: 0,
yOffset: 0,
width: 1,
height: 1,
},
]);
});
});
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "../empty-position";
import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
@@ -106,6 +106,37 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place a wide item beyond the right edge", () => {
const elements = createElementsFromLayout([["a", "a", "a", " "]]);
expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
xOffset: 0,
yOffset: 1,
});
});
});
describe("get nearest empty position", () => {
test("keeps the preferred cell when it fits", () => {
expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
xOffset: 2,
yOffset: 1,
});
});
test("chooses the nearest available cell when the preferred cell is occupied", () => {
const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
xOffset: 2,
yOffset: 0,
});
});
test("returns undefined when the item cannot fit within a bounded section", () => {
expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
@@ -34,7 +35,12 @@ export const useItemActions = () => {
const createItem = useCallback(
(input: CreateItemInput) => {
updateBoard(createItemCallback(input));
let nextBoard: Board | undefined;
updateBoard((previous) => {
nextBoard = createItemCallback(input)(previous);
return nextBoard;
});
return nextBoard;
},
[updateBoard],
);
@@ -1,7 +1,8 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import type { CSSProperties, KeyboardEvent, MutableRefObject, PointerEvent } from "react";
import { useEffect, useRef } from "react";
import { ActionIcon, Badge, Box, Card, FocusTrap } from "@mantine/core";
import { useElementSize, useMergedRef, useViewportSize } from "@mantine/hooks";
import { IconMaximize, IconPin, IconX } from "@tabler/icons-react";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -10,10 +11,14 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { getAdvancedFocusRect } from "../advanced-focus/geometry";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -33,35 +38,160 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const sourceRef = useRef<HTMLDivElement>(null);
const mergedRef = useMergedRef(ref, sourceRef);
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
const [isEditMode] = useEditMode();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const { active, open, close, dismiss, pin, hover, leave } = useAdvancedFocus();
const activeFocus = active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
const advancedRect = activeFocus
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
: null;
// Responsive layout changes remount grid items. Never leave their focus backdrop orphaned.
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
const openPinned = () => {
if (sourceRef.current) open(item.id, sourceRef.current, true);
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.currentTarget !== event.target || event.key !== "Enter" || !event.shiftKey || isEditMode) return;
event.preventDefault();
openPinned();
};
const handleSurfacePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!activeFocus || activeFocus.pinned) return;
const target =
event.target instanceof Element
? event.target.closest<HTMLElement>(
"a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, [tabindex]:not([tabindex='-1']), [contenteditable='true']",
)
: null;
const alreadyAutofocused = target?.hasAttribute("data-autofocus") ?? false;
if (target && !alreadyAutofocused) target.setAttribute("data-autofocus", "true");
pin(false);
if (target && !alreadyAutofocused) {
window.setTimeout(() => target.removeAttribute("data-autofocus"), 250);
}
};
return (
<>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef} sourceRef={sourceRef}>
<Box
ref={mergedRef}
tabIndex={isEditMode ? undefined : 0}
aria-haspopup="dialog"
aria-expanded={isAdvanced}
aria-keyshortcuts="Shift+Enter"
onKeyDown={handleKeyDown}
onPointerEnter={() => sourceRef.current && hover(item.id, sourceRef.current)}
onPointerLeave={() => leave(item.id)}
className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
<div
className={combineClasses(
advancedFocusClasses.surfaceHost,
isAdvanced && advancedFocusClasses.surfaceHostAdvanced,
)}
>
<FocusTrap active={Boolean(activeFocus?.pinned)}>
<Card
role={isAdvanced ? "dialog" : undefined}
aria-modal={activeFocus?.pinned || undefined}
aria-label={
isAdvanced ? t("item.advancedFocus.dialog", { widget: t(`widget.${item.kind}.name`) }) : undefined
}
radius={board.itemRadius}
p={0}
h={isAdvanced ? undefined : "100%"}
w={isAdvanced ? undefined : "100%"}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
isAdvanced && advancedFocusClasses.surface,
activeFocus?.phase === "closing" && advancedFocusClasses.surfaceClosing,
item.advancedOptions.customCssClasses.join(" "),
)}
onPointerDownCapture={isAdvanced ? handleSurfacePointerDown : undefined}
styles={{
root: {
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color":
item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
style={
activeFocus && advancedRect
? ({
left: advancedRect.left,
top: advancedRect.top,
width: advancedRect.width,
height: advancedRect.height,
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
"--focus-scale-x": activeFocus.sourceRect.width / advancedRect.width,
"--focus-scale-y": activeFocus.sourceRect.height / advancedRect.height,
} as CSSProperties)
: undefined
}
>
<div className={advancedFocusClasses.surfaceControls}>
{isAdvanced && (
<ActionIcon
data-autofocus={activeFocus.pinned && activeFocus.autofocusClose ? true : undefined}
className={advancedFocusClasses.closeButton}
variant="default"
size="lg"
aria-label={t("item.advancedFocus.close")}
onClick={() => close()}
>
<IconX size={18} />
</ActionIcon>
)}
{isAdvanced && (
<ActionIcon
className={advancedFocusClasses.pinButton}
variant={activeFocus.pinned ? "filled" : "default"}
aria-label={activeFocus.pinned ? t("item.advancedFocus.pinned") : t("item.advancedFocus.pin")}
aria-pressed={activeFocus.pinned}
onClick={(event) => pin(event.detail === 0)}
>
<IconPin size={18} />
</ActionIcon>
)}
{!isEditMode && !isAdvanced && (
<ActionIcon
className={advancedFocusClasses.touchButton}
variant="default"
size="lg"
aria-label={t("item.advancedFocus.open")}
onClick={openPinned}
>
<IconMaximize size={18} />
</ActionIcon>
)}
</div>
<InnerContent
item={item}
width={advancedRect?.width ?? width}
height={advancedRect?.height ?? height}
widgetStateRef={widgetStateRef}
displayMode={isAdvanced ? "advanced" : "compact"}
/>
</Card>
</FocusTrap>
</div>
</Box>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
{!isAdvanced && item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
// It's 4 because of the mantine-react-table that has z-index 3
@@ -91,6 +221,7 @@ interface InnerContentProps {
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
displayMode: "compact" | "advanced";
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -0,0 +1,25 @@
.selectionCard {
overflow: hidden;
}
.selectionActions {
opacity: 1;
transition: opacity 150ms ease;
}
@media (hover: hover) and (pointer: fine) {
.selectionActions {
opacity: 0;
}
.selectionCard:hover .selectionActions,
.selectionCard:focus-within .selectionActions {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.selectionActions {
transition: none;
}
}
@@ -3,10 +3,12 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { usePersistBoard } from "@homarr/boards/updater";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
@@ -15,15 +17,47 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
import { WidgetEditModal } from "@homarr/widgets/modals";
import { useItemActions } from "./item-actions";
import type { BoardPlacementHint, CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import classes from "./item-select-modal.module.css";
export const ItemSelectModal = createModal<void>(({ actions }) => {
interface ItemSelectModalProps {
placement?: BoardPlacementHint;
board: { id: string; name: string };
persistImmediately: boolean;
}
export const ItemSelectModal = createModal<ItemSelectModalProps>(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { createItem } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
const settings = useSettings();
const { updateAndPersistBoard } = usePersistBoard(innerProps.board);
const createAndPersist = (input: CreateItemInput) => {
try {
if (!innerProps.persistImmediately) {
createItem(input);
return;
}
updateAndPersistBoard(createItemCallback(input), {
onError: () => {
showErrorNotification({
title: t("item.create.notification.error.title"),
message: t("item.create.notification.error.message"),
});
},
});
} catch {
showErrorNotification({
title: t("item.create.notification.error.title"),
message: t("item.create.notification.error.message"),
});
}
};
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -69,8 +103,13 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const handleAddCustomWidget = (definitionId: string) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
createAndPersist({
id: itemId,
kind: "customApi",
integrationIds: [],
options: { ...defaultOptions, definitionId },
placement: innerProps.placement,
});
actions.closeModal();
};
@@ -88,7 +127,6 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
@@ -100,13 +138,19 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
updateItemOptions({ itemId, newOptions: options });
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
createAndPersist({
id: itemId,
kind,
options,
integrationIds: newIntegrationIds,
advancedOptions,
placement: innerProps.placement,
});
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
boardId: innerProps.board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
@@ -144,19 +188,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Card key={def.id} h={selectGridCardHeight} withBorder pos="relative" className={classes.selectionCard}>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
<Image
src={def.iconUrl}
alt=""
w={22}
h={22}
fit="contain"
style={{ flexShrink: 0, marginTop: 2 }}
/>
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
@@ -174,9 +217,8 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
left={0}
right={0}
p="xs"
className={classes.selectionActions}
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
@@ -223,14 +265,11 @@ const WidgetItem = ({
h={selectGridCardHeight}
withBorder
pos="relative"
className={classes.selectionCard}
style={{
overflow: "hidden",
"--_hover-opacity": "0",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
@@ -244,6 +283,11 @@ const WidgetItem = ({
{item.description}
</Text>
</Tooltip>
{hasMatchingIntegration && (
<Text size="10px" c="blue.6" fw={600}>
{t("item.create.integrationAvailable")}
</Text>
)}
<SupportedIntegrations integrations={item.supportedIntegrations} />
</Stack>
<Box
@@ -252,9 +296,8 @@ const WidgetItem = ({
left={0}
right={0}
p="xs"
className={classes.selectionActions}
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
@@ -1,35 +1,40 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
IconMaximize,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useBoardPermissions } from "../permissions/client";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -37,13 +42,16 @@ import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
sourceRef: RefObject<HTMLElement | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { updateAndPersistBoard } = usePersistBoard(board);
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
@@ -55,19 +63,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const widgetQueryKey = useMemo(
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
[currentDefinition, item.kind],
const widgetQueryKeys = useMemo(
() => getWidgetQueryKeys(currentDefinition as Parameters<typeof getWidgetQueryKeys>[0]),
[currentDefinition],
);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const matchesWidgetQuery = useCallback(
(queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)),
[widgetQueryKeys],
);
const isWidgetFetching =
useIsFetching({
type: "active",
predicate: (query) => matchesWidgetQuery(query.queryKey),
}) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
}, [queryClient, widgetQueryKey]);
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" })));
}, [queryClient, widgetQueryKeys]);
const canInteractWithSelectedIntegrations = useMemo(() => {
const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
}, [integrationsWithInteractAccess, item.integrationIds]);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
@@ -76,11 +99,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
if (!hasChangeAccess) return [];
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
}, [currentDefinition, hasChangeAccess, settings]);
const widgetContextActions = useMemo(() => {
const widgetContextActions = (() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -90,17 +114,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
context: {
isEditMode,
boardId: board.id,
itemId: item.id,
canInteractWithSelectedIntegrations,
},
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
})();
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: t("item.menu.notification.saveError.title"),
message: t("item.menu.notification.saveError.message"),
});
},
});
},
[board, saveBoard],
[t, updateAndPersistBoard],
);
const openEditModal = useCallback(() => {
@@ -113,12 +149,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
if (isEditMode) {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
} else {
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
@@ -128,7 +166,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
integrationIds: editResult.integrationIds,
},
),
);
}));
}
},
integrationData: (integrationData ?? []).filter(
@@ -154,18 +192,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
settings,
isEditMode,
persistBoard,
board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
if (isEditMode) {
updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } });
} else {
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } },
),
}));
}
},
[options, item.id, updateItemOptions, persistBoard, board],
[options, item.id, updateItemOptions, isEditMode, persistBoard],
);
if (!session) return <>{children}</>;
@@ -174,9 +216,28 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current, true);
}}
disabled={isEditMode}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
@@ -248,20 +309,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKey={widgetQueryKey}
queryKeys={widgetQueryKeys}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
{hasChangeAccess && (
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
)}
</>
{isEditMode && (
@@ -290,16 +353,20 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKey: QueryKey;
queryKeys: readonly QueryKey[];
isFetching: boolean;
t: TranslationFunction;
}
const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({ queryKey });
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({
type: "active",
predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
});
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
const ageLabel = useTimeAgo(new Date(latest));
const ageLabel = useTimeAgo(new Date(latest || Date.now()));
const ageSuffix = latest > 0 ? ` · ${ageLabel}` : "";
if (isFetching) {
return (
@@ -334,7 +401,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
<Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6">
{t("item.menu.status.error")} · {ageLabel}
{t("item.menu.status.error")}
{ageSuffix}
</Text>
</Group>
</Tooltip>
@@ -345,7 +413,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
<Group gap={4} wrap="nowrap">
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed">
{t("item.menu.status.success")} · {ageLabel}
{t("item.menu.status.success")}
{ageSuffix}
</Text>
</Group>
);
@@ -13,10 +13,8 @@
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
.wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
.wrapper {
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
@@ -0,0 +1,177 @@
"use client";
import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
import { useEffect, useRef, useState } from "react";
import type { BoxProps } from "@mantine/core";
import { Box, Menu } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { ItemSelectModal } from "../../items/item-select-modal";
import { useBoardPermissions } from "../../permissions/client";
import type { BoardPlacementHint } from "../../items/actions/create-item";
import type { UseGridstackRefs } from "./use-gridstack";
interface MenuState {
x: number;
y: number;
placement: BoardPlacementHint;
}
interface Props extends BoxProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
refs: UseGridstackRefs;
}
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const settings = useSettings();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
const { openModal } = useModalAction(ItemSelectModal);
const [isEditMode] = useEditMode();
const [menu, setMenu] = useState<MenuState | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
const clearLongPress = () => {
if (longPressTimer.current === null) return;
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
};
useEffect(
() => () => {
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
},
[],
);
const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
const wrapper = refs.wrapperElement.current;
const gridstack = refs.gridstack.current;
if (!(target instanceof Element) || !wrapper || !gridstack) return null;
if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
const rowLimit = section.kind === "dynamic" ? section.height : undefined;
if (
cell.x < 0 ||
cell.y < 0 ||
cell.x >= gridstack.getColumn() ||
(rowLimit !== undefined && cell.y >= rowLimit) ||
!gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
) {
return null;
}
return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
};
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
if (!enabled) return;
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
event.preventDefault();
event.stopPropagation();
setMenu({ x: event.clientX, y: event.clientY, placement });
};
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!enabled || event.pointerType !== "touch") return;
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
clearLongPress();
longPressTimer.current = setTimeout(() => {
setMenu({ x: event.clientX, y: event.clientY, placement });
navigator.vibrate?.(20);
}, 500);
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
!enabled ||
event.currentTarget !== event.target ||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
) {
return;
}
const wrapper = event.currentTarget;
const gridstack = refs.gridstack.current;
if (!gridstack) return;
const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
for (let yOffset = 0; yOffset < rowCount; yOffset++) {
for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
const rect = wrapper.getBoundingClientRect();
event.preventDefault();
returnFocusRef.current = wrapper;
setMenu({
x: rect.left + 24,
y: rect.top + 24,
placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
});
return;
}
}
};
return (
<>
<Box
{...boxProps}
ref={refs.wrapper}
tabIndex={enabled ? 0 : undefined}
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onPointerLeave={clearLongPress}
>
{children}
</Box>
<Menu
opened={menu !== null}
onChange={(opened) => {
if (!opened) {
setMenu(null);
const returnFocus = returnFocusRef.current;
returnFocusRef.current = null;
requestAnimationFrame(() => returnFocus?.focus());
}
}}
withinPortal
position="bottom-start"
>
<Menu.Target>
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPlus size={18} />}
onClick={() => {
returnFocusRef.current = null;
if (menu) {
openModal({ placement: menu.placement, board, persistImmediately: !isEditMode });
}
setMenu(null);
}}
>
{t("item.create.addAtPosition")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
};
@@ -1,13 +1,13 @@
"use client";
import type { BoxProps } from "@mantine/core";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { BoardEmptySpaceContextMenu } from "./board-empty-space-context-menu";
import { useGridstack } from "./use-gridstack";
interface Props extends BoxProps {
@@ -22,15 +22,16 @@ export const GridStack = ({ section, ...props }: Props) => {
return (
<SectionProvider value={{ section, items, innerSections, refs }}>
<Box
<BoardEmptySpaceContextMenu
{...props}
section={section}
refs={refs}
data-kind={section.kind}
data-section-id={section.id}
className={combineClasses(`grid-stack grid-stack-${section.kind}`, props.className)}
ref={refs.wrapper}
>
<SectionContent />
</Box>
</BoardEmptySpaceContextMenu>
</SectionProvider>
);
};
@@ -14,6 +14,7 @@ import { initializeGridstack } from "./init-gridstack";
export interface UseGridstackRefs {
wrapper: Ref<HTMLDivElement | null>;
wrapperElement: RefObject<HTMLDivElement | null>;
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
gridstack: RefObject<GridStack | null>;
}
@@ -287,6 +288,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
refs: {
items: itemRefs,
wrapper: wrapperRef,
wrapperElement: wrapperElementRef,
gridstack: gridRef,
},
};
@@ -0,0 +1 @@
export const appShellHeaderHeight = 60;
+2 -1
View File
@@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants";
import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
@@ -23,7 +24,7 @@ export const ClientShell = ({
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: 60 } : undefined}
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
navbar={
hasNavigation
? {