Compare commits

..
Author SHA1 Message Date
Thomas Camlong 59b6f08a36 fix(board): harden grid edit transitions 2026-08-21 15:49:10 +02:00
Thomas Camlong 5cd627d66c perf(board): stabilize edit mode layout 2026-08-21 15:23:18 +02:00
Bergashaandajnart 002381d294 feat(v2 docker widget): Fixes column-resize squashing (#6646)
* fix(dataTable): resizing a column no longer squashes the others

mantine-datatable's built-in column resize keeps the table's total
width fixed by shrinking every other column to compensate, which
collapses columns toward zero in a narrow widget (open upstream
issue, no fix: icflorescu/mantine-datatable#discussions-757).

Replaced it with our own resize handle that only ever changes the
width of the column being dragged - the table grows and scrolls
horizontally instead. Shared via usePersistedTableLayout, so docker,
downloads, and beszel-system-table all get it.

* feat(docker): enable hold-Shift advanced focus preview

The widget already renders differently for displayMode="advanced",
but was missing supportsAdvancedFocus: true, so the board never
offered the hold-Shift preview for it - matching downloads and
media-server, which both set this flag.

* style(docker): tighten row density, decouple icon size from row height

Circular avatar, denser row/footer padding, and action icons rendered
larger than their button via absolute positioning so they don't
inflate row height (the button stays small and fixed - it repeats
once per row, so any growth there multiplies across every visible
container).

* fix(data-table): harden shared column resizing

* fix(data-table): account for board scaling

* fix(data-table): compact shared headers

* fix(data-table): fit columns to widget

* fix(downloads): simplify advanced details

* test(widgets): align Docker advanced focus

* fix(downloads): widen expanded details

---------

Co-authored-by: ajnart <thomas@ajnart.dev>
2026-08-21 15:12:31 +02:00
Thomas 9b406d5d83 fix(beszel): restore balanced system grid layout (#6647) 2026-08-21 13:11:40 +02:00
33 changed files with 886 additions and 742 deletions

No files matched your search

@@ -20,7 +20,7 @@ import {
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
@@ -80,7 +80,6 @@ export const ClientBoard = () => {
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
@@ -25,12 +25,14 @@ import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel";
import { useBoardGridPortalHost } from "../sections/grid/grid-portal-host";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -103,9 +105,11 @@ const WidgetDefinitionLoadError = ({
);
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
const { ref, width: measuredWidth, height: measuredHeight } = useElementSize<HTMLDivElement>();
const { getEntryRuntime } = useBoardGridPortalHost();
const { widgetStateRef, widgetRuntimeRef } = getEntryRuntime(item.id, createBoardItemRuntime);
const width = measuredWidth || getLogicalTrackSize(item.width);
const height = measuredHeight || getLogicalTrackSize(item.height);
return (
<ErrorBoundary
@@ -134,6 +138,11 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
);
};
const createBoardItemRuntime = () => ({
widgetStateRef: { current: null } as MutableRefObject<Record<string, unknown> | null>,
widgetRuntimeRef: { current: createWidgetRuntimeState() } as WidgetRuntimeRef,
});
interface LoadedBoardItemContentProps {
item: SectionItem;
width: number;
@@ -62,7 +62,7 @@ export const BoardContainerSection = ({ section }: Props) => {
radius={board.itemRadius}
p={0}
>
{options.collapsible && !isEditMode && (
{options.collapsible && (
<Button
className={classes.containerToggle}
pos="absolute"
@@ -71,7 +71,7 @@ export const BoardContainerSection = ({ section }: Props) => {
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={options.showOpenAll ? 40 : 12}
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
@@ -101,7 +101,7 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
</Button>
)}
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
<Badge
pos="absolute"
top={-24}
@@ -2,13 +2,16 @@
import type { ComponentType, PropsWithChildren } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useEditMode } from "@homarr/boards/edit-mode";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { getLoadedGridEditorModule, loadGridEditorAsync } from "./grid-editor-loader";
import { GridEditorRegistryProvider, useRegisteredGridEditors } from "./grid-editor-registry";
import { GridEditorRuntimeProvider } from "./grid-editor-runtime";
type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
type LoadedGridEditorModule = Awaited<ReturnType<typeof loadGridEditorAsync>>;
/**
* Keeps the dnd-kit runtime outside the read-only bundle while guaranteeing
@@ -16,17 +19,20 @@ type BoardGridEditorProviderComponent = ComponentType<PropsWithChildren>;
*/
export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
const [isEditMode] = useEditMode();
const [Provider, setProvider] = useState<BoardGridEditorProviderComponent | null>(null);
const [EditorModule, setEditorModule] = useState<LoadedGridEditorModule | null>(
getLoadedGridEditorModule() ?? null,
);
const [hasLoadError, setHasLoadError] = useState(false);
const resolvedEditorModule = EditorModule ?? getLoadedGridEditorModule() ?? null;
useEffect(() => {
if (!isEditMode || Provider) return;
if (!isEditMode || resolvedEditorModule) return;
let cancelled = false;
setHasLoadError(false);
void loadGridEditorAsync()
.then((module) => {
if (!cancelled) setProvider(() => module.BoardGridEditorProvider);
if (!cancelled) setEditorModule(module);
})
.catch(() => {
if (!cancelled) setHasLoadError(true);
@@ -35,23 +41,34 @@ export const BoardGridEditorBoundary = ({ children }: PropsWithChildren) => {
return () => {
cancelled = true;
};
}, [Provider, isEditMode]);
}, [isEditMode, resolvedEditorModule]);
if (!isEditMode) {
return <GridEditorRuntimeProvider status="idle">{children}</GridEditorRuntimeProvider>;
}
if (hasLoadError) {
return <GridEditorRuntimeProvider status="error">{children}</GridEditorRuntimeProvider>;
}
if (!Provider) {
return <GridEditorRuntimeProvider status="loading">{children}</GridEditorRuntimeProvider>;
}
const runtimeStatus = !isEditMode
? "idle"
: hasLoadError
? "error"
: resolvedEditorModule
? "ready"
: "loading";
const Provider: BoardGridEditorProviderComponent | null = resolvedEditorModule?.BoardGridEditorProvider ?? null;
const GridEditor = resolvedEditorModule?.default ?? null;
return (
<GridEditorRuntimeProvider status="ready">
<Provider>{children}</Provider>
</GridEditorRuntimeProvider>
<GridEditorRegistryProvider>
<GridEditorRuntimeProvider status={runtimeStatus}>{children}</GridEditorRuntimeProvider>
{isEditMode && Provider && GridEditor && (
<Provider>
<RegisteredGridEditors GridEditor={GridEditor} />
</Provider>
)}
</GridEditorRegistryProvider>
);
};
const RegisteredGridEditors = ({ GridEditor }: { GridEditor: LoadedGridEditorModule["default"] }) => {
const editors = useRegisteredGridEditors();
return Array.from(editors.values(), ({ host, disabled, ...props }) =>
disabled ? null : createPortal(<GridEditor {...props} />, host, props.sectionId),
);
};
@@ -1,7 +1,7 @@
"use client";
import type { FocusEvent, KeyboardEvent, PropsWithChildren } from "react";
import { useId, useRef, useState } from "react";
import { useCallback, useId, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -34,7 +34,7 @@ interface FixedGridItemProps {
/**
* Static SSR geometry in view mode and the stable content/keyboard handle in
* edit mode. The focused tile owns grid-based keyboard movement while dnd-kit
* handles pointer and touch input on the outer editor shell.
* binds pointer and touch input to this same element without replacing it.
*/
export const FixedGridItem = ({
item,
@@ -46,11 +46,16 @@ export const FixedGridItem = ({
const runtimeStatus = useGridEditorRuntimeStatus();
const currentLayoutId = useCurrentLayout();
const t = useI18n();
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
const { section, columnCount, maxRowCount, placements, interactionDisabled, announce, entryElementStore } =
useSectionContext();
const { commitSectionGrid } = useGridLayoutActions();
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
const { isSelected, toggleSelectItem } = useBoardSelection();
const selected = item.type === "item" && isSelected(item.id);
const registerElement = useCallback(
(element: HTMLDivElement | null) => entryElementStore.register(item.id, element),
[entryElementStore, item.id],
);
const handleClickCapture = (event: React.MouseEvent) => {
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
@@ -60,15 +65,14 @@ export const FixedGridItem = ({
toggleSelectItem(item.id);
};
const instructionsId = useId();
const isEditorActive = isEditMode && runtimeStatus === "ready";
const isEditorActive = isEditMode && runtimeStatus === "ready" && !interactionDisabled;
const placement = toPlacement(item);
const renderedPlacements = [...items, ...innerSections].map(toPlacement);
const keyboardGridRef = useRef<{
source: readonly SectionGridPlacement[];
current: readonly SectionGridPlacement[];
}>({
source: renderedPlacements,
current: renderedPlacements,
source: placements,
current: placements,
});
const displayName =
item.type === "item"
@@ -111,14 +115,14 @@ export const FixedGridItem = ({
setKeyboardEditing(false);
return;
}
if (!arePlacementListsEqual(keyboardGridRef.current.source, renderedPlacements)) {
if (!arePlacementListsEqual(keyboardGridRef.current.source, placements)) {
keyboardGridRef.current = {
source: renderedPlacements,
current: renderedPlacements,
source: placements,
current: placements,
};
}
const placements = keyboardGridRef.current.current;
const current = placements.find((candidate) => candidate.id === item.id);
const currentPlacements = keyboardGridRef.current.current;
const current = currentPlacements.find((candidate) => candidate.id === item.id);
if (!current) return;
let transaction: GridTransaction<SectionGridPlacement>;
@@ -130,7 +134,7 @@ export const FixedGridItem = ({
id: section.id,
columnCount,
maxRowCount,
placements,
placements: currentPlacements,
},
],
},
@@ -138,8 +142,8 @@ export const FixedGridItem = ({
);
} catch {
keyboardGridRef.current = {
source: renderedPlacements,
current: renderedPlacements,
source: currentPlacements,
current: currentPlacements,
};
setKeyboardEditing(false);
return;
@@ -188,13 +192,22 @@ export const FixedGridItem = ({
return (
<Box
ref={registerElement}
{...semantics}
onClickCapture={handleClickCapture}
data-board-item-selected={selected ? "true" : undefined}
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
data-grid-item-id={isEditorActive ? undefined : item.id}
data-grid-item-type={isEditorActive ? undefined : item.type}
className={combineClasses(
classes.staticItem,
isEditorActive && "board-grid-entry",
isEditorActive && classes.editorEntry,
)}
style={getLogicalItemStyle(placement)}
data-grid-item-id={item.id}
data-grid-item-type={item.type}
data-grid-x={placement.x}
data-grid-y={placement.y}
data-grid-w={placement.w}
data-grid-h={placement.h}
data-editor-grid-entry={isEditorActive ? "true" : undefined}
data-type={item.type}
data-kind={item.kind}
@@ -209,7 +222,7 @@ export const FixedGridItem = ({
onBlur={isEditorActive ? handleBlur : undefined}
onKeyDown={isEditorActive ? handleKeyboard : undefined}
>
{children}
<div className={combineClasses("board-grid-content-mount", classes.contentMount)}>{children}</div>
{isEditorActive && (
<span id={instructionsId} className={classes.liveRegion}>
{t("item.moveResize.keyboard.instructions")}
@@ -3,16 +3,24 @@
import type * as GridEditorModule from "./grid-editor";
let editorModulePromise: Promise<typeof GridEditorModule> | undefined;
let editorModule: typeof GridEditorModule | undefined;
export const loadGridEditorAsync = () => {
editorModulePromise ??= import("./grid-editor").catch((error: unknown) => {
editorModulePromise = undefined;
throw error;
});
editorModulePromise ??= import("./grid-editor")
.then((loadedModule) => {
editorModule = loadedModule;
return loadedModule;
})
.catch((error: unknown) => {
editorModulePromise = undefined;
throw error;
});
return editorModulePromise;
};
export const getLoadedGridEditorModule = () => editorModule;
export const scheduleGridEditorWarmup = (loadAsync: () => Promise<unknown>) => {
let animationFrameId: number | undefined;
let idleCallbackId: number | undefined;
@@ -0,0 +1,117 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useState, useSyncExternalStore } from "react";
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface GridEntryElementSnapshot {
revision: number;
elements: ReadonlyMap<string, HTMLElement>;
}
export interface GridEntryElementStore {
getSnapshot: () => GridEntryElementSnapshot;
subscribe: (listener: () => void) => () => void;
register: (id: string, element: HTMLElement | null) => void;
}
export const createGridEntryElementStore = (): GridEntryElementStore => {
const elements = new Map<string, HTMLElement>();
const listeners = new Set<() => void>();
let snapshot: GridEntryElementSnapshot = { revision: 0, elements };
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
register: (id, element) => {
const current = elements.get(id);
if (element ? current === element : current === undefined) return;
if (element) elements.set(id, element);
else elements.delete(id);
snapshot = { revision: snapshot.revision + 1, elements };
for (const listener of listeners) listener();
},
};
};
export interface GridEditorRenderProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
transactionPlacements: readonly SectionGridPlacement[];
className: string;
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
items: SectionItem[];
innerSections: ContainerSectionItem[];
entryElementStore: GridEntryElementStore;
}
export interface RegisteredGridEditor extends GridEditorRenderProps {
host: HTMLElement;
disabled: boolean;
}
interface GridEditorRegistrySnapshot {
revision: number;
editors: ReadonlyMap<string, RegisteredGridEditor>;
}
interface GridEditorRegistry {
getSnapshot: () => GridEditorRegistrySnapshot;
subscribe: (listener: () => void) => () => void;
register: (editor: RegisteredGridEditor) => () => void;
}
const createGridEditorRegistry = (): GridEditorRegistry => {
const editors = new Map<string, RegisteredGridEditor>();
const listeners = new Set<() => void>();
let snapshot: GridEditorRegistrySnapshot = { revision: 0, editors };
const publish = () => {
snapshot = { revision: snapshot.revision + 1, editors };
for (const listener of listeners) listener();
};
return {
getSnapshot: () => snapshot,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
register: (editor) => {
editors.set(editor.sectionId, editor);
publish();
return () => {
if (editors.get(editor.sectionId) !== editor) return;
editors.delete(editor.sectionId);
publish();
};
},
};
};
const GridEditorRegistryContext = createContext<GridEditorRegistry | null>(null);
export const GridEditorRegistryProvider = ({ children }: PropsWithChildren) => {
const [registry] = useState(createGridEditorRegistry);
return <GridEditorRegistryContext.Provider value={registry}>{children}</GridEditorRegistryContext.Provider>;
};
export const useGridEditorRegistry = () => {
const registry = useContext(GridEditorRegistryContext);
if (!registry) throw new Error("GridEditorRegistryProvider is required");
return registry;
};
export const useRegisteredGridEditors = () => {
const registry = useGridEditorRegistry();
return useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot).editors;
};
@@ -3,6 +3,7 @@
inset: 0 auto auto 0;
overflow: visible;
border-radius: var(--mantine-radius-md);
pointer-events: none;
}
.board-grid-entry {
@@ -22,6 +23,13 @@
will-change: translate;
}
.board-grid-entry[data-grid-preview="true"]:not([data-dnd-active="true"]) {
transition:
translate 100ms ease,
width 100ms ease,
height 100ms ease;
}
.board-grid-entry[data-dnd-active="true"],
.board-grid-entry[data-dnd-drag-source="true"] {
z-index: 1000;
@@ -101,24 +109,12 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-entry[data-dnd-active="true"],
body[data-board-grid-interacting] .board-grid-entry[data-dnd-drag-source="true"],
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
.board-grid-content-mount,
.board-grid-portal-content,
.board-grid-portal-content > [data-editor-grid-entry] {
width: 100%;
height: 100%;
overflow: visible;
}
.board-grid-content-mount {
position: absolute;
inset: 0;
}
body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="true"] > .board-grid-content-mount {
overflow: clip;
}
@@ -13,6 +13,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { createPortal } from "react-dom";
import "./grid-editor.css"; // oxlint-disable-line import/no-unassigned-import
@@ -37,8 +38,7 @@ import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import {
beginGridTransaction,
cancelGridTransaction,
@@ -57,27 +57,18 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import type { GridEditorRenderProps } from "./grid-editor-registry";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
const GRID_ENTRY_TYPE = "homarr-board-grid-entry";
const GRID_TARGET_TYPE = "homarr-board-grid-target";
interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
className: string;
}
interface GridEntryData {
kind: typeof GRID_ENTRY_TYPE;
sectionId: string;
@@ -727,14 +718,22 @@ export default function GridEditor({
rowCount,
maxRowCount,
placements,
transactionPlacements,
className,
}: GridEditorProps) {
section,
items,
innerSections,
entryElementStore,
}: GridEditorRenderProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const interactionStore = useBoardGridEditorInteractionStore();
const gridRef = useRef<HTMLDivElement>(null);
const [entryElements] = useState(() => new Map<string, HTMLElement>());
const entryElements = useSyncExternalStore(
entryElementStore.subscribe,
entryElementStore.getSnapshot,
entryElementStore.getSnapshot,
).elements;
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getDepth(sectionId);
@@ -769,14 +768,6 @@ export default function GridEditor({
},
[droppableRef],
);
const registerEntryElement = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) entryElements.set(id, element);
else entryElements.delete(id);
},
[entryElements],
);
useLayoutEffect(() => {
const element = gridRef.current;
if (!element) return;
@@ -786,11 +777,11 @@ export default function GridEditor({
maxRowCount,
parentGridId,
ownerPlacementId,
placements,
placements: transactionPlacements,
depth,
element,
});
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, registerGrid, sectionId, transactionPlacements]);
return (
<div
@@ -815,7 +806,7 @@ export default function GridEditor({
label={labelById.get(placement.id) ?? placement.id}
columnCount={columnCount}
maxRowCount={maxRowCount}
registerElement={registerEntryElement}
element={entryElements.get(placement.id) ?? null}
/>
);
})}
@@ -844,24 +835,18 @@ const DndGridEntryComponent = ({
label,
columnCount,
maxRowCount,
registerElement,
element,
}: DndGridEntryProps) => {
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const { isSelected } = useBoardSelection();
const board = useRequiredBoard();
const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
placementRef.current = placement;
const resizeOutlineController = useMemo(
() =>
createGridResizeOutlineController(
() => shellRef.current,
() => element,
() => placementRef.current,
),
[],
[element],
);
const sourceData = useMemo<GridEntryData>(
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
@@ -882,67 +867,39 @@ const DndGridEntryComponent = ({
const modifiers = useMemo(
() => [
RestrictToElement.configure({
element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
element: () => element?.closest('[data-testid="board-canvas"]') ?? null,
}),
],
[],
[element],
);
const {
ref: draggableRef,
handleRef,
isDragSource,
isDropping,
} = useDraggable<GridEntryData>({
const { isDragSource, isDropping } = useDraggable<GridEntryData>({
id: placement.id,
type: GRID_ENTRY_TYPE,
data: sourceData,
element,
handle: element,
disabled: element === null,
sensors,
modifiers,
});
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
handleRef(element);
},
[draggableRef, handleRef, placement.id, registerElement],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
useLayoutEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const container = acquireContainer(placement.id);
mount.replaceChildren(container);
if (!element) return;
if (isDragSource) element.dataset.dndDragSource = "true";
else delete element.dataset.dndDragSource;
if (isDropping) element.dataset.dndDropping = "true";
else delete element.dataset.dndDropping;
return () => {
if (container.parentElement === mount) container.remove();
delete element.dataset.dndDragSource;
delete element.dataset.dndDropping;
};
}, [acquireContainer, placement.id, structureRevision]);
}, [element, isDragSource, isDropping]);
return (
<div
ref={setShellRef}
className="board-grid-entry"
style={
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
data-grid-y={placement.y}
data-grid-w={placement.w}
data-grid-h={placement.h}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
data-board-item-selected={isSelectedEntry ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
if (!element) return null;
return createPortal(
<>
<GridResizeHandles
sectionId={sectionId}
placement={placement}
@@ -955,7 +912,9 @@ const DndGridEntryComponent = ({
cancelResize={cancelResize}
scheduleResizeOutline={resizeOutlineController.schedule}
/>
</div>
</>,
element,
`board-grid-resize-handles:${placement.id}`,
);
};
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
label: string;
columnCount: number;
maxRowCount: number | null;
registerElement: (id: string, element: HTMLElement | null) => void;
element: HTMLElement | null;
}
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
previous.label === next.label &&
previous.columnCount === next.columnCount &&
previous.maxRowCount === next.maxRowCount &&
previous.registerElement === next.registerElement &&
previous.element === next.element &&
previous.placement.id === next.placement.id &&
previous.placement.type === next.placement.type &&
previous.placement.x === next.placement.x &&
@@ -2,38 +2,23 @@
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRootSectionLane } from "@homarr/definitions";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import classes from "./section-grid.module.css";
interface GridPortalHostContextValue {
announce: (message: string) => void;
integrations: RouterOutputs["integration"]["all"] | undefined;
containers: ReadonlyMap<string, HTMLElement>;
acquireContainer: (id: string) => HTMLElement;
structureRevision: string;
getEntryRuntime: <TRuntime>(entryId: string, createRuntime: () => TRuntime) => TRuntime;
}
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
/**
* One portal registry owns every edit-mode item on the board. Its content
* containers are stable even when drag-and-drop replaces or transfers the
* outer positioning shell, so moving between grids does not remount widget
* content.
*/
/** Shared board-grid services for editor announcements and integration data. */
export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const { data: session } = useSession();
@@ -41,51 +26,27 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
const { data: integrations } = clientApi.integration.all.useQuery(undefined, {
enabled: Boolean(session) && isEditMode,
});
const containersRef = useRef<Map<string, HTMLElement>>(new Map());
const [containers, setContainers] = useState<Map<string, HTMLElement>>(() => new Map());
const [announcement, setAnnouncement] = useState({ id: 0, message: "" });
const entryRuntimesRef = useRef(new Map<string, unknown>());
const announce = useCallback((message: string) => {
setAnnouncement((previous) => ({ id: previous.id + 1, message }));
}, []);
const liveEntryIds = useMemo(
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
[board.items, board.sections],
);
const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
const acquireContainer = useCallback((id: string) => {
const existing = containersRef.current.get(id);
if (existing) return existing;
const container = document.createElement("div");
container.className = "board-grid-portal-content";
container.dataset.boardGridPortal = id;
containersRef.current.set(id, container);
setContainers(new Map(containersRef.current));
return container;
const getEntryRuntime = useCallback(<TRuntime,>(entryId: string, createRuntime: () => TRuntime): TRuntime => {
const existing = entryRuntimesRef.current.get(entryId);
if (existing !== undefined) return existing as TRuntime;
const runtime = createRuntime();
entryRuntimesRef.current.set(entryId, runtime);
return runtime;
}, []);
const liveItemIds = useMemo(() => new Set(board.items.map((item) => item.id)), [board.items]);
useEffect(() => {
// A cross-grid move can briefly have no shell, but the entry still exists
// on the board. Only remove containers whose board entity was deleted.
let didPrune = false;
for (const id of containersRef.current.keys()) {
if (liveEntryIds.has(id)) continue;
containersRef.current.delete(id);
didPrune = true;
for (const entryId of entryRuntimesRef.current.keys()) {
if (!liveItemIds.has(entryId)) entryRuntimesRef.current.delete(entryId);
}
if (didPrune) setContainers(new Map(containersRef.current));
}, [liveEntryIds]);
}, [liveItemIds]);
const value = useMemo<GridPortalHostContextValue>(
() => ({
announce,
integrations,
containers,
acquireContainer,
structureRevision,
}),
[acquireContainer, announce, containers, integrations, structureRevision],
() => ({ announce, integrations, getEntryRuntime }),
[announce, getEntryRuntime, integrations],
);
return (
@@ -98,158 +59,8 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
);
};
/**
* Renders stable portal content at this component's logical React position.
* Keep it inside the editor runtime and dnd-kit providers so portaled entries
* inherit the same keyboard, transaction, and nested-grid contexts as shells.
*/
export const BoardGridPortalRenderer = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { announce, containers, integrations } = useBoardGridPortalHost();
return (
<>
{Array.from(containers, ([id, container]) => {
const ownerSectionId = getOwnerSectionId(board, currentLayoutId, id);
return ownerSectionId
? createPortal(
<GridPortalContent
itemId={id}
ownerSectionId={ownerSectionId}
integrations={integrations}
announce={announce}
/>,
container,
id,
)
: null;
})}
</>
);
};
export const useBoardGridPortalHost = () => {
const context = useContext(GridPortalHostContext);
if (!context) throw new Error("BoardGridPortalHost is required");
return context;
};
interface GridPortalContentProps {
itemId: string;
ownerSectionId: string;
integrations: RouterOutputs["integration"]["all"] | undefined;
announce: (message: string) => void;
}
const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: GridPortalContentProps) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
if (!section || !currentLayout) return null;
const columnCount = getSectionColumnCount(section, currentLayout);
if (columnCount < 1) return null;
const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
if (!entry) return null;
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
const contentRowCount = Math.max(
1,
...renderedItems.map((item) => item.yOffset + item.height),
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
);
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
return (
<SectionProvider
value={{
section,
items: renderedItems,
innerSections: renderedInnerSections,
integrations,
columnCount,
maxRowCount,
announce,
}}
>
<SectionContentItem item={entry} integrations={integrations} />
</SectionProvider>
);
};
const getRenderedSection = (
board: ReturnType<typeof useRequiredBoard>,
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
sectionId: string,
visited = new Set<string>(),
): Exclude<Section, { kind: "container" }> | ContainerSectionItem | null => {
const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind !== "container") return section;
if (visited.has(section.id)) return null;
const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
if (!layout) return null;
const parentSection = getRenderedSection(
board,
currentLayout,
layout.parentSectionId,
new Set(visited).add(section.id),
);
if (!parentSection) return null;
const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
if (parentColumnCount < 1) return null;
const normalized = normalizePortalEntry(layout, parentColumnCount);
return {
...section,
...normalized,
type: "section",
};
};
const getSectionColumnCount = (
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem,
currentLayout: ReturnType<typeof useRequiredBoard>["layouts"][number],
) =>
section.kind === "container"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const normalizePortalEntry = <
TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
>(
entry: TEntry,
columnCount: number,
) => {
const placement = normalizeGridPlacement(
{ id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
columnCount,
);
return {
...entry,
xOffset: placement.x,
yOffset: placement.y,
width: placement.w,
height: placement.h,
};
};
const getOwnerSectionId = (board: ReturnType<typeof useRequiredBoard>, layoutId: string, entryId: string) => {
const item = board.items.find((candidate) => candidate.id === entryId);
if (item) {
return item.layouts.find((layout) => layout.layoutId === layoutId)?.sectionId ?? null;
}
const section = board.sections.find((candidate) => candidate.kind === "container" && candidate.id === entryId);
if (!section || section.kind !== "container") return null;
return section.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
};
@@ -14,19 +14,23 @@
inset: 0 auto auto 0;
}
.editorPortalHost {
display: contents;
}
.staticItem {
overflow: visible;
transition:
left 220ms ease,
top 220ms ease,
width 220ms ease,
height 220ms ease;
}
.contentMount {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.editorEntry {
position: relative;
width: 100%;
height: 100%;
overflow: visible;
cursor: grab;
user-select: none;
@@ -43,19 +47,6 @@
border-radius: var(--mantine-radius-md);
}
.editorLoading {
position: absolute;
inset: 0;
z-index: 20;
overflow: hidden;
}
.editorLoadingItem {
background-color: var(--mantine-color-default);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
.liveRegion {
position: absolute;
width: 1px;
@@ -67,9 +58,3 @@
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.staticItem {
transition: none;
}
}
@@ -1,13 +1,12 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Box, LoadingOverlay } from "@mantine/core";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import {
@@ -15,56 +14,21 @@ import {
getEditableCanvasAttributes,
getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
import { SectionContent } from "../content";
import { useCollapsedSectionIds } from "../section-collapse";
import { SectionProvider, useSectionContext } from "../section-context";
import { useCollapsedSectionIds, useExpandSectionsForEditing } from "../section-collapse";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css";
const GridEditor = dynamic(loadGridEditorAsync, {
loading: GridEditorLoading,
ssr: false,
});
function GridEditorLoading() {
const t = useI18n();
const { items, innerSections } = useSectionContext();
return (
<Box
component="output"
className={classes.editorLoading}
data-testid="board-grid-editor-loading"
aria-label={t("common.action.loading")}
>
{[...items, ...innerSections].map((item) => (
<Box
key={item.id}
className={classes.editorLoadingItem}
style={getLogicalItemStyle({
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
})}
aria-hidden="true"
/>
))}
<LoadingOverlay visible />
</Box>
);
}
interface SectionGridProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number;
@@ -85,21 +49,19 @@ export const SectionGrid = ({
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
const [entryElementStore] = useState(createGridEntryElementStore);
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(section.id);
const { announce, integrations } = useBoardGridPortalHost();
const collapsedSectionIds = useCollapsedSectionIds();
const minimumBySectionId = useMemo(
() =>
new Map(
innerSections.map((innerSection) => [
innerSection.id,
getContainerMinimumSize(board, currentLayoutId, innerSection.id),
]),
),
[board, currentLayoutId, innerSections],
);
const expandSectionsForEditing = useExpandSectionsForEditing();
const minimumBySectionId = useMemo(() => {
const minimumSizes = getContainerMinimumSizes(board, currentLayoutId);
return new Map(innerSections.map((innerSection) => [innerSection.id, minimumSizes.get(innerSection.id)]));
}, [board, currentLayoutId, innerSections]);
const placements = useMemo(
() =>
@@ -121,41 +83,112 @@ export const SectionGrid = ({
}),
[columnCount, innerSections, items, minimumBySectionId],
);
const collapsibleSectionIds = useMemo(
() =>
new Set(
innerSections.filter((innerSection) => innerSection.options.collapsible).map((innerSection) => innerSection.id),
),
[innerSections],
);
const directCollapsedIds = useMemo(
() =>
new Set(
placements
.filter(
(placement) =>
placement.type === "section" &&
collapsedSectionIds.has(placement.id) &&
collapsibleSectionIds.has(placement.id),
)
.map((placement) => placement.id),
),
[collapsedSectionIds, collapsibleSectionIds, placements],
);
const displayPlacements = useMemo(() => {
const directCollapsedIds = new Set(
placements
.filter(
(placement) =>
placement.type === "section" &&
collapsedSectionIds.has(placement.id) &&
innerSections.find((innerSection) => innerSection.id === placement.id)?.options.collapsible,
)
.map((placement) => placement.id),
);
if (directCollapsedIds.size === 0) return placements;
return getCollapsedDisplayLayout(placements, {
columnCount,
collapsedItemIds: directCollapsedIds,
});
}, [collapsedSectionIds, columnCount, innerSections, placements]);
}, [columnCount, directCollapsedIds, placements]);
const placementById = useMemo(
() => new Map(displayPlacements.map((placement) => [placement.id, placement])),
[displayPlacements],
);
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
const displayedItems = useMemo(
() => items.map((item) => withPlacement(item, placementById.get(item.id))),
[items, placementById],
);
const displayedInnerSections = useMemo(
() => innerSections.map((item) => withPlacement(item, placementById.get(item.id))),
[innerSections, placementById],
);
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
? getEditableCanvasAttributes({ label, columnCount, rowCount })
: getReadonlyCanvasAttributes({ label });
// 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);
const canvasAttributes =
isEditMode && !isInteractionDisabled
? getEditableCanvasAttributes({ label, columnCount, rowCount })
: getReadonlyCanvasAttributes({ label });
const editorClassName = combineClasses("board-grid-editor", classes.editorGrid);
const expandCollapsedSectionsForPointerEdit = (event: PointerEvent<HTMLDivElement>) => {
if (!isEditMode || directCollapsedIds.size === 0 || event.button !== 0) return;
const target = event.target;
if (!(target instanceof Element)) return;
const entry = target.closest('[data-editor-grid-entry="true"]');
if (!entry || !event.currentTarget.contains(entry)) return;
if (!target.closest(".board-grid-resize-handle") && target.closest(INTERACTIVE_GRID_SELECTOR)) return;
expandSectionsForEditing(directCollapsedIds);
};
const expandCollapsedSectionsForKeyboardEdit = (event: KeyboardEvent<HTMLDivElement>) => {
if (!isEditMode || directCollapsedIds.size === 0 || !EDIT_ACTIVATION_KEYS.has(event.key)) return;
const target = event.target;
if (!(target instanceof Element) || !target.matches('[data-editor-grid-entry="true"]')) return;
expandSectionsForEditing(directCollapsedIds);
};
useLayoutEffect(() => {
const host = editorHostRef.current;
if (!host) return;
return editorRegistry.register({
host,
disabled: isInteractionDisabled,
sectionId: section.id,
section,
items: displayedItems,
innerSections: displayedInnerSections,
columnCount,
rowCount,
maxRowCount,
placements: displayPlacements,
transactionPlacements: placements,
className: editorClassName,
entryElementStore,
});
}, [
columnCount,
displayPlacements,
displayedInnerSections,
displayedItems,
editorClassName,
editorRegistry,
entryElementStore,
isInteractionDisabled,
maxRowCount,
placements,
rowCount,
section,
]);
return (
<SectionProvider
@@ -166,47 +199,48 @@ export const SectionGrid = ({
integrations,
columnCount,
maxRowCount,
placements: displayPlacements,
interactionDisabled: isInteractionDisabled,
announce,
entryElementStore,
}}
>
<Box
{...canvasAttributes}
className={combineClasses(classes.viewport, className)}
style={{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
}}
style={
{
width: logicalWidth,
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-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
>
{isEditMode && editorRuntimeStatus === "ready" ? (
<GridEditor
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={displayPlacements}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
) : isEditMode && editorRuntimeStatus === "loading" ? (
<GridEditorLoading />
) : (
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
</Box>
)}
<Box
className={classes.staticGrid}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
</SectionProvider>
);
};
const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
const [visualHeight, setVisualHeight] = useState(0);
@@ -225,37 +259,70 @@ const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
};
const getContainerMinimumSize = (
board: ReturnType<typeof useRequiredBoard>,
layoutId: string,
sectionId: string,
visited = new Set<string>(),
): { width: number; height: number } => {
if (visited.has(sectionId)) return { width: 1, height: 1 };
const nextVisited = new Set(visited).add(sectionId);
const directItems: PlacementBounds[] = board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.sectionId === sectionId ? [layout] : [];
});
const directSections: PlacementBounds[] = board.sections.flatMap((candidate) => {
if (candidate.kind !== "container") return [];
const layout = candidate.layouts.find((candidateLayout) => candidateLayout.layoutId === layoutId);
if (!layout || layout.parentSectionId !== sectionId) return [];
const minimum = getContainerMinimumSize(board, layoutId, candidate.id, nextVisited);
return [
{
...layout,
width: Math.max(layout.width, minimum.width),
height: Math.max(layout.height, minimum.height),
},
];
});
const children = [...directItems, ...directSections];
const containerMinimumSizeCache = new WeakMap<
ReturnType<typeof useRequiredBoard>,
Map<string, ReadonlyMap<string, { width: number; height: number }>>
>();
return {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, layoutId: string) => {
const cachedByLayout = containerMinimumSizeCache.get(board);
const cached = cachedByLayout?.get(layoutId);
if (cached) return cached;
const directItemsBySectionId = new Map<string, PlacementBounds[]>();
for (const item of board.items) {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const entries = directItemsBySectionId.get(layout.sectionId) ?? [];
entries.push(layout);
directItemsBySectionId.set(layout.sectionId, entries);
}
const directSectionsBySectionId = new Map<string, { id: string; placement: PlacementBounds }[]>();
for (const section of board.sections) {
if (section.kind !== "container") continue;
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const entries = directSectionsBySectionId.get(layout.parentSectionId) ?? [];
entries.push({ id: section.id, placement: layout });
directSectionsBySectionId.set(layout.parentSectionId, entries);
}
const minimumBySectionId = new Map<string, { width: number; height: number }>();
const visiting = new Set<string>();
const resolve = (sectionId: string): { width: number; height: number } => {
const existing = minimumBySectionId.get(sectionId);
if (existing) return existing;
if (visiting.has(sectionId)) return { width: 1, height: 1 };
visiting.add(sectionId);
const itemBounds = directItemsBySectionId.get(sectionId) ?? [];
const sectionBounds = (directSectionsBySectionId.get(sectionId) ?? []).map(({ id, placement }) => {
const minimum = resolve(id);
return {
...placement,
width: Math.max(placement.width, minimum.width),
height: Math.max(placement.height, minimum.height),
};
});
const children = [...itemBounds, ...sectionBounds];
const minimum = {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
};
visiting.delete(sectionId);
minimumBySectionId.set(sectionId, minimum);
return minimum;
};
for (const section of board.sections) {
if (section.kind === "container") resolve(section.id);
}
const nextByLayout = cachedByLayout ?? new Map();
nextByLayout.set(layoutId, minimumBySectionId);
containerMinimumSizeCache.set(board, nextByLayout);
return minimumBySectionId;
};
interface PlacementBounds {
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
commitSectionGrid: vi.fn(),
isSelected: vi.fn(() => false),
toggleSelectItem: vi.fn(),
registerElement: vi.fn(),
editMode: true,
maxRowCount: null as number | null,
items: [
@@ -79,7 +80,17 @@ vi.mock("../../section-context", () => ({
innerSections: [],
columnCount: 3,
maxRowCount: mocks.maxRowCount,
placements: mocks.items.map((item) => ({
id: item.id,
type: item.type,
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
})),
interactionDisabled: false,
announce: mocks.announce,
entryElementStore: { register: mocks.registerElement },
}),
}));
@@ -98,6 +109,7 @@ describe("fixed grid item behavior", () => {
beforeEach(() => {
mocks.announce.mockReset();
mocks.commitSectionGrid.mockReset();
mocks.registerElement.mockReset();
mocks.editMode = true;
mocks.maxRowCount = null;
getWeatherMock().advancedOptions.title = null;
@@ -22,7 +22,6 @@ describe("grid entry memoization", () => {
{
"data-grid-id": props.placement.id,
"data-x": props.placement.x,
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
},
createElement(WidgetProbe),
);
@@ -79,21 +78,17 @@ describe("grid entry memoization", () => {
});
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
const elements = new Map<string, HTMLElement>();
const interactionStore = createGridInteractionStore<GridInteraction>();
const gridRef = { current: null as HTMLDivElement | null };
const setGridRef = (element: HTMLDivElement | null) => {
gridRef.current = element;
};
const registerElement = (id: string, element: HTMLElement | null) => {
if (element) elements.set(id, element);
else elements.delete(id);
};
const entries = Array.from({ length: 100 }, (_, index) => ({
...createProps(),
registerElement,
element: document.createElement("span"),
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
const elements = new Map(entries.map((entry) => [entry.placement.id, entry.element]));
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
act(() =>
root.render(
@@ -160,7 +155,7 @@ describe("grid entry memoization", () => {
test.each([
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })],
["bound element", (props: DndGridEntryProps) => ({ ...props, element: document.createElement("span") })],
[
"minimum size",
(props: DndGridEntryProps) => ({
@@ -198,5 +193,5 @@ const createProps = (): DndGridEntryProps => ({
label: "Weather",
columnCount: 12,
maxRowCount: null,
registerElement: () => undefined,
element: null,
});
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
const gridDirectory = resolve(import.meta.dirname, "..");
const nextjsSourceDirectory = resolve(gridDirectory, "../../../../");
const sectionGridPath = resolve(gridDirectory, "section-grid.tsx");
const editorBoundaryPath = resolve(gridDirectory, "board-grid-editor-boundary.tsx");
const editorLoaderPath = resolve(gridDirectory, "grid-editor-loader.ts");
describe("board grid import boundary", () => {
@@ -33,15 +34,16 @@ describe("board grid import boundary", () => {
]);
});
test("loads the editor through next/dynamic only in the edit branch", () => {
const source = readFileSync(sectionGridPath, "utf8");
test("loads the editor only in edit mode without replacing static board content", () => {
const sectionSource = readFileSync(sectionGridPath, "utf8");
const boundarySource = readFileSync(editorBoundaryPath, "utf8");
expect(source).toMatch(/import dynamic from ["']next\/dynamic["']/);
expect(source).toMatch(
/const GridEditor = dynamic\(loadGridEditorAsync,\s*{[\s\S]*?loading:\s*GridEditorLoading,[\s\S]*?ssr:\s*false,[\s\S]*?}\);/,
);
expect(source.match(/<GridEditor\b/g)).toHaveLength(1);
expect(source).toMatch(/isEditMode\s*&&\s*editorRuntimeStatus\s*===\s*["']ready["']\s*\?\s*\(\s*<GridEditor\b/);
expect(sectionSource).not.toMatch(/import dynamic from ["']next\/dynamic["']/);
expect(sectionSource).toMatch(/<SectionContent\s*\/>/);
expect(sectionSource).toMatch(/<div ref={editorHostRef} className={classes\.editorPortalHost}\s*\/>/);
expect(boundarySource).toMatch(/loadGridEditorAsync\(\)/);
expect(boundarySource).toMatch(/isEditMode\s*&&\s*Provider\s*&&\s*GridEditor/);
expect(boundarySource).toMatch(/createPortal\(<GridEditor \{\.\.\.props} \/>, host, props\.sectionId\)/);
expect(getRuntimeImports(sectionGridPath).filter(({ specifier }) => specifier === "./grid-editor")).toEqual([]);
expect(
getRuntimeImports(editorLoaderPath)
@@ -1,104 +0,0 @@
import { act, createElement, useLayoutEffect } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BoardGridPortalHost, useBoardGridPortalHost } from "../grid-portal-host";
const mocks = vi.hoisted(() => ({
board: {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
sections: [],
},
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
integration: {
all: {
useQuery: () => ({ data: undefined }),
},
},
},
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { id: "user" } } }),
}));
vi.mock("@homarr/boards/context", () => ({
useCurrentLayout: () => "desktop",
useRequiredBoard: () => mocks.board,
}));
vi.mock("@homarr/boards/edit-mode", () => ({
useEditMode: () => [true],
}));
describe("board grid portal host", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
mocks.board = {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "root" }] }],
sections: [],
};
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
test("keeps a portal across relocation and prunes it after permanent deletion", () => {
const acquired: HTMLElement[] = [];
renderHost(root, true, 0, acquired);
expect(getContainerCount(container)).toBe("1");
mocks.board = {
items: [{ id: "weather", layouts: [{ layoutId: "desktop", sectionId: "other" }] }],
sections: [],
};
renderHost(root, true, 1, acquired);
expect(getContainerCount(container)).toBe("1");
expect(new Set(acquired).size).toBe(1);
mocks.board = { items: [], sections: [] };
renderHost(root, false, 2, acquired);
expect(getContainerCount(container)).toBe("0");
});
});
const renderHost = (root: Root, acquire: boolean, revision: number, acquired: HTMLElement[]) => {
act(() => {
root.render(
createElement(BoardGridPortalHost, null, createElement(PortalRegistryProbe, { acquire, revision, acquired })),
);
});
};
const PortalRegistryProbe = ({
acquire,
revision,
acquired,
}: {
acquire: boolean;
revision: number;
acquired: HTMLElement[];
}) => {
const portalHost = useBoardGridPortalHost();
useLayoutEffect(() => {
if (acquire) acquired.push(portalHost.acquireContainer("weather"));
}, [acquire, acquired, portalHost, revision]);
return createElement("span", { "data-container-count": String(portalHost.containers.size) });
};
const getContainerCount = (container: HTMLElement) =>
container.querySelector<HTMLElement>("[data-container-count]")?.dataset.containerCount;
@@ -12,13 +12,16 @@ import { readSectionCollapsedFromStorage, writeSectionCollapsedToStorage } from
interface SectionCollapseContextValue {
collapsedSectionIds: ReadonlySet<string>;
visuallyCollapsedSectionIds: ReadonlySet<string>;
setCollapsed: (sectionId: string, collapsed: boolean) => void;
expandForEditing: (sectionIds: ReadonlySet<string>) => void;
}
const SectionCollapseContext = createContext<SectionCollapseContextValue | null>(null);
export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() =>
@@ -27,8 +30,13 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
),
);
const collapsedByIdRef = useRef(collapsedById);
const [temporarilyExpandedIds, setTemporarilyExpandedIds] = useState<ReadonlySet<string>>(() => new Set());
collapsedByIdRef.current = collapsedById;
useEffect(() => {
if (!isEditMode) setTemporarilyExpandedIds((current) => (current.size === 0 ? current : new Set()));
}, [isEditMode]);
useEffect(() => {
setCollapsedById((previous) => {
const next = { ...previous };
@@ -57,6 +65,12 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
const previousCollapsed = collapsedByIdRef.current[sectionId] ?? false;
collapsedByIdRef.current = { ...collapsedByIdRef.current, [sectionId]: collapsed };
setCollapsedById((previous) => ({ ...previous, [sectionId]: collapsed }));
setTemporarilyExpandedIds((current) => {
if (!current.has(sectionId)) return current;
const next = new Set(current);
next.delete(sectionId);
return next;
});
if (status === "authenticated") {
mutate(
@@ -76,47 +90,70 @@ export const BoardSectionCollapseProvider = ({ children }: PropsWithChildren) =>
[mutate, status],
);
const value = useMemo<SectionCollapseContextValue>(
() => ({
collapsedSectionIds: new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
const expandForEditing = useCallback(
(sectionIds: ReadonlySet<string>) => {
if (!isEditMode || sectionIds.size === 0) return;
setTemporarilyExpandedIds((current) => {
const next = new Set(current);
for (const sectionId of sectionIds) next.add(sectionId);
return next.size === current.size ? current : next;
});
},
[isEditMode],
);
const value = useMemo<SectionCollapseContextValue>(() => {
const collapsedSectionIds = new Set(
Object.entries(collapsedById)
.filter(([, collapsed]) => collapsed)
.map(([sectionId]) => sectionId),
);
return {
collapsedSectionIds,
visuallyCollapsedSectionIds: new Set(
[...collapsedSectionIds].filter((sectionId) => !isEditMode || !temporarilyExpandedIds.has(sectionId)),
),
setCollapsed,
}),
[collapsedById, setCollapsed],
);
expandForEditing,
};
}, [collapsedById, expandForEditing, isEditMode, setCollapsed, temporarilyExpandedIds]);
return <SectionCollapseContext.Provider value={value}>{children}</SectionCollapseContext.Provider>;
};
export const useSectionCollapse = ({ sectionId, collapsible }: { sectionId: string; collapsible: boolean }) => {
const context = useContext(SectionCollapseContext);
const [isEditMode] = useEditMode();
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
const isCollapsed = collapsible && context.collapsedSectionIds.has(sectionId);
const isVisuallyCollapsed = collapsible && context.visuallyCollapsedSectionIds.has(sectionId);
return {
isCollapsed,
isVisuallyCollapsed: isCollapsed && !isEditMode,
isVisuallyCollapsed,
setCollapsed: (collapsed: boolean) => context.setCollapsed(sectionId, collapsed),
toggle: () => context.setCollapsed(sectionId, !isCollapsed),
toggle: () => context.setCollapsed(sectionId, !isVisuallyCollapsed),
};
};
export const useCollapsedSectionIds = () => {
const context = useContext(SectionCollapseContext);
const [isEditMode] = useEditMode();
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return isEditMode ? EMPTY_SET : context.collapsedSectionIds;
return context.visuallyCollapsedSectionIds;
};
const EMPTY_SET = new Set<string>();
export const useExpandSectionsForEditing = () => {
const context = useContext(SectionCollapseContext);
if (!context) {
throw new Error("BoardSectionCollapseProvider is required");
}
return context.expandForEditing;
};
@@ -3,6 +3,8 @@ import { createContext, useContext } from "react";
import type { RouterOutputs } from "@homarr/api";
import type { ContainerSectionItem, Section, SectionItem } from "~/app/[locale]/boards/_types";
import type { GridEntryElementStore } from "./grid/grid-editor-registry";
import type { SectionGridPlacement } from "./grid/use-grid-layout-actions";
interface SectionContextProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
@@ -11,7 +13,10 @@ interface SectionContextProps {
integrations: RouterOutputs["integration"]["all"] | undefined;
columnCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
interactionDisabled: boolean;
announce: (message: string) => void;
entryElementStore: GridEntryElementStore;
}
const SectionContext = createContext<SectionContextProps | null>(null);
@@ -7,47 +7,55 @@ import type { ContainerSectionItem, SectionItem } from "~/app/[locale]/boards/_t
export const useSectionItems = (sectionId: string): { innerSections: ContainerSectionItem[]; items: SectionItem[] } => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const innerSections = useMemo(
() =>
board.sections
.filter((innerSection) => innerSection.kind === "container")
.map(({ layouts, ...innerSection }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!layout) return null;
return {
...layout,
...innerSection,
type: "section" as const,
};
})
.filter((item) => item !== null)
.filter((innerSection) => innerSection.parentSectionId === sectionId),
[board.sections, currentLayoutId, sectionId],
);
const items = useMemo(
() =>
board.items
.map(({ layouts, ...item }) => {
const layout = layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!layout) return null;
return {
...layout,
...item,
type: "item" as const,
};
})
.filter((item) => item !== null)
.filter((item) => item.sectionId === sectionId),
[board.items, currentLayoutId, sectionId],
);
const index = useMemo(() => getSectionItemsIndex(board, currentLayoutId), [board, currentLayoutId]);
return {
innerSections,
items,
innerSections: index.innerSectionsByParentId.get(sectionId) ?? EMPTY_INNER_SECTIONS,
items: index.itemsBySectionId.get(sectionId) ?? EMPTY_ITEMS,
};
};
interface SectionItemsIndex {
innerSectionsByParentId: ReadonlyMap<string, ContainerSectionItem[]>;
itemsBySectionId: ReadonlyMap<string, SectionItem[]>;
}
const sectionItemsIndexCache = new WeakMap<
ReturnType<typeof useRequiredBoard>,
Map<string, SectionItemsIndex>
>();
const EMPTY_INNER_SECTIONS: ContainerSectionItem[] = [];
const EMPTY_ITEMS: SectionItem[] = [];
const getSectionItemsIndex = (board: ReturnType<typeof useRequiredBoard>, layoutId: string): SectionItemsIndex => {
const cachedByLayout = sectionItemsIndexCache.get(board);
const cached = cachedByLayout?.get(layoutId);
if (cached) return cached;
const innerSectionsByParentId = new Map<string, ContainerSectionItem[]>();
for (const section of board.sections) {
if (section.kind !== "container") continue;
const { layouts, ...definition } = section;
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const entries = innerSectionsByParentId.get(layout.parentSectionId) ?? [];
entries.push({ ...layout, ...definition, type: "section" });
innerSectionsByParentId.set(layout.parentSectionId, entries);
}
const itemsBySectionId = new Map<string, SectionItem[]>();
for (const item of board.items) {
const { layouts, ...definition } = item;
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) continue;
const entries = itemsBySectionId.get(layout.sectionId) ?? [];
entries.push({ ...layout, ...definition, type: "item" });
itemsBySectionId.set(layout.sectionId, entries);
}
const index = { innerSectionsByParentId, itemsBySectionId };
const nextByLayout = cachedByLayout ?? new Map();
nextByLayout.set(layoutId, index);
sectionItemsIndexCache.set(board, nextByLayout);
return index;
};
+23 -23
View File
@@ -4836,70 +4836,70 @@
},
"items": {
"actions": {
"columnTitle": "Controls"
"columnTitle": "Actions"
},
"added": {
"columnTitle": "Added",
"detailsTitle": "Date Added"
"detailsTitle": "Added"
},
"category": {
"columnTitle": "Extras",
"detailsTitle": "Categories (Or extra information)"
"columnTitle": "Category",
"detailsTitle": "Category"
},
"downSpeed": {
"columnTitle": "Down",
"detailsTitle": "Download Speed"
"detailsTitle": "Down"
},
"index": {
"columnTitle": "#",
"detailsTitle": "Current index within client"
"detailsTitle": "Index"
},
"id": {
"columnTitle": "Id",
"detailsTitle": "Item ID"
"columnTitle": "ID",
"detailsTitle": "ID"
},
"integration": {
"columnTitle": "Integration"
"columnTitle": "Source"
},
"name": {
"columnTitle": "Job name"
"columnTitle": "Name"
},
"progress": {
"columnTitle": "Progress",
"detailsTitle": "Download Progress"
"detailsTitle": "Progress"
},
"ratio": {
"columnTitle": "Ratio",
"detailsTitle": "Torrent ratio (received/sent)"
"detailsTitle": "Ratio"
},
"received": {
"columnTitle": "Total down",
"detailsTitle": "Total downloaded"
"columnTitle": "Down",
"detailsTitle": "Downloaded"
},
"sent": {
"columnTitle": "Total up",
"detailsTitle": "Total Uploaded"
"columnTitle": "Up",
"detailsTitle": "Uploaded"
},
"size": {
"columnTitle": "File Size",
"detailsTitle": "Total Size of selection/files"
"columnTitle": "Size",
"detailsTitle": "Size"
},
"state": {
"columnTitle": "State",
"detailsTitle": "Job State"
"detailsTitle": "State"
},
"time": {
"columnTitle": "Finish time",
"detailsTitle": "Time since/to completion",
"columnTitle": "ETA",
"detailsTitle": "ETA",
"eta": "ETA"
},
"type": {
"columnTitle": "Type",
"detailsTitle": "Download Client type"
"detailsTitle": "Type"
},
"upSpeed": {
"columnTitle": "Up",
"detailsTitle": "Upload Speed"
"detailsTitle": "Up"
}
},
"states": {
@@ -12,11 +12,18 @@
background-color: transparent;
}
.homarr-data-table .mantine-datatable-table {
width: 100% !important;
max-width: 100%;
table-layout: fixed !important;
}
.homarr-data-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.homarr-data-table th {
padding: 0;
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
@@ -28,6 +35,13 @@
text-overflow: ellipsis;
}
/* Lets a cell's content (e.g. an oversized action icon) visually overflow its own box without
growing it - used to make a per-row icon look bigger without inflating row height, since the
icon is pulled out of flow via absolute positioning rather than actually being a bigger cell. */
.homarr-data-table td.docker-actions-cell {
overflow: visible;
}
.homarr-data-table td .mantine-Text-root,
.homarr-data-table td .mantine-Group-root {
white-space: nowrap;
@@ -40,6 +54,25 @@
transition: opacity 150ms ease-out;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-group {
gap: 0;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon {
margin: 0;
}
.homarr-data-table .mantine-datatable-header-cell-sortable-unsorted-icon {
display: none;
}
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-text svg,
.homarr-data-table .mantine-datatable-header-cell-sortable-icon svg {
width: var(--mantine-font-size-md);
height: var(--mantine-font-size-md);
}
.homarr-data-table th:hover .mantine-datatable-header-cell-sortable-icon,
.homarr-data-table th:hover .mantine-datatable-header-cell-draggable-action-icon,
.homarr-data-table .mantine-datatable-header-cell-draggable-action-icon:focus-visible,
@@ -2,10 +2,24 @@
import "./homarr-data-table.css";
import type { DataTableProps } from "mantine-datatable";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { DataTableColumn, DataTableProps } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
const fallbackColumnWidth = 60;
const getColumnWidth = (width: string | number | undefined): number => {
if (typeof width === "number" && Number.isFinite(width) && width > 0) return width;
if (typeof width === "string") {
const parsedWidth = Number.parseFloat(width);
if (Number.isFinite(parsedWidth) && parsedWidth > 0) return parsedWidth;
}
return fallbackColumnWidth;
};
type HomarrDataTableProps<T> = DataTableProps<T> & {
columns: DataTableColumn<T>[];
groups?: never;
isEditMode: boolean;
cellPadding: string;
rowCursor?: "auto" | "default";
@@ -16,11 +30,78 @@ export function HomarrDataTable<T>({
cellPadding,
rowCursor = "auto",
className,
columns,
defaultColumnProps,
onMouseDownCapture,
scrollAreaProps,
style,
...props
}: HomarrDataTableProps<T>) {
const [resizePreviewWidths, setResizePreviewWidths] = useState<Record<string, number>>();
const removeResizeListeners = useRef<() => void>(() => undefined);
useEffect(() => () => removeResizeListeners.current(), []);
const handleMouseDownCapture = useCallback(
(event: React.MouseEvent<HTMLTableElement>) => {
onMouseDownCapture?.(event);
const target = event.target;
if (!(target instanceof Element)) return;
const handle = target.closest(".mantine-datatable-header-resizable-handle");
const currentCell = handle?.closest<HTMLTableCellElement>("th[data-accessor]");
const headerRow = currentCell?.parentElement;
if (!currentCell || !headerRow) return;
const cells = Array.from(headerRow.querySelectorAll<HTMLTableCellElement>("th[data-accessor]"));
const currentIndex = cells.indexOf(currentCell);
const nextCell = cells[currentIndex + 1];
const currentAccessor = currentCell.dataset.accessor;
const nextAccessor = nextCell?.dataset.accessor;
if (!nextCell || !currentAccessor || !nextAccessor) return;
const widths = Object.fromEntries(
cells.flatMap((cell) => {
const accessor = cell.dataset.accessor;
if (!accessor) return [];
return [[accessor, cell.getBoundingClientRect().width]];
}),
);
const currentWidth = widths[currentAccessor];
const nextWidth = widths[nextAccessor];
if (currentWidth === undefined || nextWidth === undefined) return;
const visualScale = currentCell.getBoundingClientRect().width / currentCell.offsetWidth;
const minimumWidth = fallbackColumnWidth * visualScale;
const startX = event.clientX;
removeResizeListeners.current();
const handleMouseMove = (moveEvent: MouseEvent) => {
const delta = Math.min(
nextWidth - minimumWidth,
Math.max(minimumWidth - currentWidth, moveEvent.clientX - startX),
);
setResizePreviewWidths({
...widths,
[currentAccessor]: currentWidth + delta,
[nextAccessor]: nextWidth - delta,
});
};
const handleResizeEnd = () => {
removeResizeListeners.current();
requestAnimationFrame(() => requestAnimationFrame(() => setResizePreviewWidths(undefined)));
};
removeResizeListeners.current = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("blur", handleResizeEnd);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("blur", handleResizeEnd);
},
[onMouseDownCapture],
);
const mergedClassName = [
"homarr-data-table",
rowCursor === "default" ? "homarr-data-table-row-cursor-default" : undefined,
@@ -28,6 +109,26 @@ export function HomarrDataTable<T>({
]
.filter(Boolean)
.join(" ");
const fittedColumns = useMemo(() => {
const visibleColumns = columns.filter(({ hidden }) => !hidden);
const getRenderedWidth = (column: DataTableColumn<T>) =>
resizePreviewWidths?.[String(column.accessor)] ?? getColumnWidth(column.width);
const totalWidth = visibleColumns.reduce((total, column) => total + getRenderedWidth(column), 0);
if (totalWidth <= 0) return columns;
return columns.map((column) => {
if (column.hidden) return column;
const width = `${(getRenderedWidth(column) / totalWidth) * 100}%`;
const fittedWidth = { width, minWidth: width, maxWidth: width };
const cellsStyle = column.cellsStyle ?? defaultColumnProps?.cellsStyle;
return {
...column,
titleStyle: [column.titleStyle, fittedWidth],
cellsStyle: (record: T, index: number) => [cellsStyle?.(record, index), fittedWidth, { padding: cellPadding }],
};
});
}, [cellPadding, columns, defaultColumnProps?.cellsStyle, resizePreviewWidths]);
return (
<DataTable<T>
@@ -42,6 +143,8 @@ export function HomarrDataTable<T>({
textSelectionDisabled
height="100%"
{...props}
columns={fittedColumns}
onMouseDownCapture={handleMouseDownCapture}
className={mergedClassName}
style={[{ pointerEvents: isEditMode ? "none" : undefined }, style]}
defaultColumnProps={{
@@ -107,7 +107,7 @@ export const parseColumnWidths = (value: string, columnAccessors: readonly strin
}
};
export const usePersistedTableLayout = <T>({
export const usePersistedTableLayout = <T,>({
columns,
columnAccessors,
columnOrder,
+1 -1
View File
@@ -39,7 +39,6 @@ describe("supportsAdvancedFocus", () => {
beszelSystemStatsDefinition,
beszelSystemTableDefinition,
customApiDefinition,
dockerDefinition,
indexerManagerDefinition,
notebookDefinition,
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
@@ -49,6 +48,7 @@ describe("supportsAdvancedFocus", () => {
test("requires an explicit opt-in", () => {
expect(supportsAdvancedFocus({})).toBe(false);
expect(supportsAdvancedFocus(clockDefinition)).toBe(true);
expect(supportsAdvancedFocus(dockerDefinition)).toBe(true);
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
});
});
+49 -16
View File
@@ -69,6 +69,18 @@ interface ContainerActionHandlers {
const columnAccessors = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
const containerMenuWidth = 240;
// The *button* sizes stay small and fixed - row height is set by the tallest cell, and the
// actions button is repeated once per row, so any growth here multiplies across every row and
// tanks how many containers fit on screen. The *icon* sizes are rendered larger than their
// button via absolute positioning (see ContainerMenuButton/footer refresh below) - an
// absolutely-positioned element is removed from normal flow, so it doesn't count toward its
// ancestor's height even though it visually overflows the button. This lets the icon look
// properly sized without the button (and therefore the row) growing to match.
const rowActionButtonSize = 22;
const rowActionIconVisualSize = 32;
const footerRefreshButtonSize = 24;
const footerRefreshIconVisualSize = 30;
const createContainerLogsPath = (container: Pick<DockerContainer, "endpointId" | "id" | "name">) =>
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}&endpointId=${encodeURIComponent(container.endpointId)}`;
@@ -83,7 +95,13 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="xs" radius="sm" variant="light" color={containerStateColorMap[state]}>
<Badge
size="xs"
radius="sm"
variant="light"
color={containerStateColorMap[state]}
styles={{ label: { lineHeight: 1.2 } }}
>
{t(state)}
</Badge>
);
@@ -126,16 +144,15 @@ const createColumns = (
<Group gap="xs" wrap="nowrap" style={{ overflow: "hidden" }}>
{inlineState && <ContainerStateDot state={container.state} />}
<Avatar
variant="outline"
radius="sm"
size={20}
radius="xl"
size={24}
styles={{ image: { objectFit: "contain" } }}
src={container.iconUrl}
style={{ flexShrink: 0 }}
>
{container.name.at(0)?.toUpperCase()}
</Avatar>
<Text size="sm" truncate>
<Text size="sm" lh={1.2} truncate>
{container.name}
</Text>
</Group>
@@ -191,8 +208,12 @@ const createColumns = (
{
accessor: "actions",
title: "",
width: 44,
width: rowActionButtonSize + 18,
textAlign: "right",
// The default `.homarr-data-table td { overflow: hidden }` (needed for name/host ellipsis
// truncation) would clip the actions icon's intentional visual overflow - opt this one
// cell out of it.
cellsClassName: "docker-actions-cell",
render: (container) => <ContainerMenuButton container={container} handlers={handlers} />,
},
];
@@ -407,7 +428,7 @@ export default function DockerWidget({
<Box style={{ flex: 1, minHeight: 0 }}>
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={width < 400 ? "2px 8px" : "4px 8px"}
cellPadding="2px 8px"
rowCursor="default"
fetching={isFetching && containers.length === 0}
fz={width < 400 ? "xs" : "sm"}
@@ -431,38 +452,43 @@ export default function DockerWidget({
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
py={2}
px={8}
wrap="nowrap"
>
<Group gap={4} wrap="nowrap">
<IconBrandDocker size="var(--mantine-font-size-xl)" style={{ flexShrink: 0 }} />
<Text size="sm" truncate>
<IconBrandDocker size="var(--mantine-font-size-md)" style={{ flexShrink: 0 }} />
<Text size="xs" truncate>
{t("table.footer", { count: containers.length.toString() })}
</Text>
</Group>
<Group gap="sm" wrap="nowrap" justify="flex-end" style={{ minWidth: 0 }}>
{footerVisibility.cpu && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
</Text>
)}
{footerVisibility.memory && (
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
{t("table.totalMemory", { memory: formatBytes(totals.memory) })}
</Text>
)}
<Tooltip label={t("table.refresh.lastUpdated", { when: relativeTime })}>
<ActionIcon
className={actionTargetClasses.root}
size="sm"
size={footerRefreshButtonSize}
variant="transparent"
c="var(--mantine-color-text)"
loading={isFetching || refreshInventory.isPending}
onClick={() => refreshInventory.mutate()}
aria-label={t("table.refresh.lastUpdated", { when: relativeTime })}
style={{ position: "relative", overflow: "visible" }}
>
<IconRefresh size="var(--mantine-font-size-md)" />
<IconRefresh
size={footerRefreshIconVisualSize}
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
</ActionIcon>
</Tooltip>
</Group>
@@ -492,11 +518,18 @@ function ContainerMenuButton({
className={actionTargetClasses.root}
variant="subtle"
color="gray"
size="sm"
size={rowActionButtonSize}
aria-label={t("title")}
onClick={(event) => event.stopPropagation()}
style={{ position: "relative", overflow: "visible" }}
>
<IconDots size="var(--mantine-font-size-md)" />
{/* Rendered larger than the button and pulled out of flow via absolute positioning,
so it looks properly sized without the button - and therefore the row - growing
to match (row height is set by the tallest cell, and this button repeats per row). */}
<IconDots
size={rowActionIconVisualSize}
style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}
/>
</ActionIcon>
</Menu.Target>
<Menu.Dropdown w={containerMenuWidth} miw={containerMenuWidth} maw={containerMenuWidth}>
+1
View File
@@ -26,6 +26,7 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
supportsAdvancedFocus: true,
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {
+21 -13
View File
@@ -793,7 +793,9 @@ export default function DownloadClientsWidget({
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
content: ({ record, collapse }) => (
<ExpandedRow item={record} collapse={collapse} />
),
}}
onScroll={() => {
if (contextMenu) closeContextMenu();
@@ -869,7 +871,13 @@ function buildHoverTooltip(record: ExtendedDownloadClientItem, t: DownloadsT): R
);
}
function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) {
function ExpandedRow({
item,
collapse,
}: {
item: ExtendedDownloadClientItem;
collapse: () => void;
}) {
const t = useScopedI18n("widget.downloads");
const locale = useCurrentIntlLocale();
const progressPercent = Math.floor(item.progress * 100);
@@ -891,13 +899,13 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
thickness={5}
roundCaps
sections={[{ value: progressPercent, color: progressColor(item.state, item.progress) }]}
label={<Text ta="center" fw={700} style={{ fontSize: 10 }}>{`${progressPercent}%`}</Text>}
label={<Text size="xs" ta="center" fw={700}>{`${progressPercent}%`}</Text>}
/>
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Avatar size={16} radius={0} src={getIconUrl(item.integration.kind)} />
<Text size="xs" fw={600} truncate>
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="var(--mantine-font-size-md)" radius={0} src={getIconUrl(item.integration.kind)} />
<Text size="sm" fw={600} truncate style={{ minWidth: 0 }}>
{item.name}
</Text>
<Badge size="xs" variant="light" color={stateColorMap[item.state]} ml="auto" style={{ flexShrink: 0 }}>
@@ -905,7 +913,7 @@ function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; col
</Badge>
</Group>
<SimpleGrid cols={3} spacing={4} verticalSpacing={2}>
<SimpleGrid cols={3} spacing="xs" verticalSpacing={4} style={{ minWidth: 0 }}>
<DetailPair
label={t("items.size.detailsTitle")}
value={`${formatBytes(item.received)} / ${formatBytes(item.size)}`}
@@ -957,17 +965,17 @@ function DetailPair({
color?: string;
}) {
return (
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap", fontSize: 10 }}>
<Stack gap={0} style={{ minWidth: 0, overflow: "hidden" }}>
<Text size="xs" c="dimmed" truncate>
{label}:
</Text>
<Group gap={2} wrap="nowrap">
<Group gap={2} wrap="nowrap" style={{ minWidth: 0 }}>
{icon}
<Text size="xs" fw={500} c={color} truncate style={{ fontSize: 10 }}>
<Text size="sm" fw={500} c={color} truncate style={{ minWidth: 0 }}>
{value}
</Text>
</Group>
</Group>
</Stack>
);
}
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import {
DOWNLOAD_COLUMN_ACCESSORS,
ADVANCED_DOWNLOAD_COLUMN_ACCESSORS,
filterDownloadItemsByStatus,
getAvailableDownloadStates,
getDownloadColumnAccessors,
@@ -25,8 +25,8 @@ describe("downloads filters", () => {
});
describe("downloads advanced columns", () => {
test("uses every column in advanced mode without changing the compact selection", () => {
expect(getDownloadColumnAccessors(["name"], true)).toEqual(DOWNLOAD_COLUMN_ACCESSORS);
test("uses summary columns in advanced mode without changing the compact selection", () => {
expect(getDownloadColumnAccessors(["name"], true)).toEqual(ADVANCED_DOWNLOAD_COLUMN_ACCESSORS);
expect(getDownloadColumnAccessors(["name"], false)).toEqual(["name"]);
});
});
+12 -1
View File
@@ -20,6 +20,17 @@ export const DOWNLOAD_COLUMN_ACCESSORS = [
"type",
] as const satisfies readonly (keyof ExtendedDownloadClientItem)[];
export const ADVANCED_DOWNLOAD_COLUMN_ACCESSORS = [
"name",
"progress",
"size",
"downSpeed",
"upSpeed",
"time",
"state",
"integration",
] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][];
type DownloadColumnAccessor = (typeof DOWNLOAD_COLUMN_ACCESSORS)[number];
export function getDownloadsStatsDisplay(showCompactStats: boolean, isAdvanced: boolean) {
@@ -33,7 +44,7 @@ export function getDownloadColumnAccessors(
configuredColumns: readonly DownloadColumnAccessor[],
isAdvanced: boolean,
): readonly DownloadColumnAccessor[] {
return isAdvanced ? DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
return isAdvanced ? ADVANCED_DOWNLOAD_COLUMN_ACCESSORS : configuredColumns;
}
export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
+17
View File
@@ -0,0 +1,17 @@
diff --git a/dist/index.js b/dist/index.js
index 1ac8241b321236fb9f43feb9405151da1fd3d975..9fb5fff13b2bfd2d1db4a7d16003f1fa9e0fb699 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,2 +1,2 @@
-"use strict";"use client";var pr=Object.create;var Dt=Object.defineProperty;var fr=Object.getOwnPropertyDescriptor;var br=Object.getOwnPropertyNames;var gr=Object.getPrototypeOf,Tr=Object.prototype.hasOwnProperty;var hr=(e,t)=>{for(var n in t)Dt(e,n,{get:t[n],enumerable:!0})},Rn=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let a of br(t))!Tr.call(e,a)&&a!==n&&Dt(e,a,{get:()=>t[a],enumerable:!(o=fr(t,a))||o.enumerable});return e};var q=(e,t,n)=>(n=e!=null?pr(gr(e)):{},Rn(t||!e||!e.__esModule?Dt(n,"default",{value:e,enumerable:!0}):n,e)),Cr=e=>Rn(Dt({},"__esModule",{value:!0}),e);var xr={};hr(xr,{DataTable:()=>ra,DataTableDraggableRow:()=>sa,calculateColSpan:()=>it,differenceBy:()=>an,flattenColumns:()=>Xe,getGroupsAtDepth:()=>vt,getMaxGroupDepth:()=>xt,getRecordId:()=>F,getValueAtPath:()=>Rt,hasStringAccessor:()=>rt,humanize:()=>Ie,needsRightBorder:()=>rn,reorderRecords:()=>yr,sanitizeStoredArray:()=>pe,swapRecords:()=>Pr,uniqBy:()=>lt,useDataTableColumns:()=>ln});module.exports=Cr(xr);var Bt=require("@mantine/core"),Wt=require("@mantine/hooks"),Tn=q(require("clsx")),J=require("react");var xn=require("@mantine/core");function V(e,t,n){return e?(0,xn.parseThemeColor)({color:typeof e=="object"?e[n]:e,theme:t}).value:void 0}function vn({theme:e,c:t,backgroundColor:n,borderColor:o,rowBorderColor:a,stripedColor:r,highlightOnHoverColor:l}){return{"--mantine-datatable-color-light":V(t,e,"light"),"--mantine-datatable-color-dark":V(t,e,"dark"),"--mantine-datatable-background-color-light":V(n,e,"light"),"--mantine-datatable-background-color-dark":V(n,e,"dark"),"--mantine-datatable-border-color-light":V(o,e,"light"),"--mantine-datatable-border-color-dark":V(o,e,"dark"),"--mantine-datatable-row-border-color-light":V(a,e,"light"),"--mantine-datatable-row-border-color-dark":V(a,e,"dark"),"--mantine-datatable-striped-color-light":V(r,e,"light"),"--mantine-datatable-striped-color-dark":V(r,e,"dark"),"--mantine-datatable-highlight-on-hover-color-light":V(l,e,"light"),"--mantine-datatable-highlight-on-hover-color-dark":V(l,e,"dark")}}function wt({theme:e,paginationActiveTextColor:t,paginationActiveBackgroundColor:n}){return{"--mantine-datatable-pagination-active-text-color-light":V(t,e,"light"),"--mantine-datatable-pagination-active-text-color-dark":V(t,e,"dark"),"--mantine-datatable-pagination-active-background-color-light":V(n,e,"light"),"--mantine-datatable-pagination-active-background-color-dark":V(n,e,"dark")}}function Mn({theme:e,color:t,backgroundColor:n}){return{"--mantine-datatable-row-color-light":V(t,e,"light"),"--mantine-datatable-row-color-dark":V(t,e,"dark"),"--mantine-datatable-row-background-color-light":V(n,e,"light"),"--mantine-datatable-row-background-color-dark":V(n,e,"dark")}}var en=require("react");var kn=require("@mantine/core"),[En,$e]=(0,kn.createSafeContext)("useDataTableColumnsContext must be used within DataTableColumnProvider");var Ln=require("react/jsx-runtime"),tn=e=>{let{children:t,columnsOrder:n,setColumnsOrder:o,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S,pinnedMap:b}=e,[C,E]=(0,en.useState)(""),[D,L]=(0,en.useState)("");return(0,Ln.jsx)(En,{value:{sourceColumn:C,setSourceColumn:E,targetColumn:D,setTargetColumn:L,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,swapColumns:()=>{if(!n||!o||!C||!D)return;let f=b.get(C),h=b.get(D);if(f?.logicalSide!==h?.logicalSide)return;let w=n.indexOf(C),M=n.indexOf(D);if(w!==-1&&M!==-1){let g=[...n],[R]=g.splice(w,1);g.splice(M,0,R),o(g)}},resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S},children:t})};var nn=require("react/jsx-runtime");function In(){return(0,nn.jsx)("tr",{className:"mantine-datatable-empty-row",children:(0,nn.jsx)("td",{})})}var St=require("@mantine/core");var be=require("react/jsx-runtime");function Nn(){return(0,be.jsxs)("svg",{width:"24",height:"24",viewBox:"0 0 24 24",strokeWidth:"2",stroke:"currentColor",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[(0,be.jsx)("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),(0,be.jsx)("path",{d:"M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74"}),(0,be.jsx)("path",{d:"M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6"}),(0,be.jsx)("path",{d:"M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4"}),(0,be.jsx)("path",{d:"M3 3l18 18"})]})}var me=require("react/jsx-runtime");function Hn({icon:e,text:t,active:n,children:o}){return(0,me.jsx)(St.Center,{className:"mantine-datatable-empty-state","data-active":n||void 0,children:o||(0,me.jsxs)(me.Fragment Line truncated
+"use strict";"use client";var pr=Object.create;var Dt=Object.defineProperty;var fr=Object.getOwnPropertyDescriptor;var br=Object.getOwnPropertyNames;var gr=Object.getPrototypeOf,Tr=Object.prototype.hasOwnProperty;var hr=(e,t)=>{for(var n in t)Dt(e,n,{get:t[n],enumerable:!0})},Rn=(e,t,n,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let a of br(t))!Tr.call(e,a)&&a!==n&&Dt(e,a,{get:()=>t[a],enumerable:!(o=fr(t,a))||o.enumerable});return e};var q=(e,t,n)=>(n=e!=null?pr(gr(e)):{},Rn(t||!e||!e.__esModule?Dt(n,"default",{value:e,enumerable:!0}):n,e)),Cr=e=>Rn(Dt({},"__esModule",{value:!0}),e);var xr={};hr(xr,{DataTable:()=>ra,DataTableDraggableRow:()=>sa,calculateColSpan:()=>it,differenceBy:()=>an,flattenColumns:()=>Xe,getGroupsAtDepth:()=>vt,getMaxGroupDepth:()=>xt,getRecordId:()=>F,getValueAtPath:()=>Rt,hasStringAccessor:()=>rt,humanize:()=>Ie,needsRightBorder:()=>rn,reorderRecords:()=>yr,sanitizeStoredArray:()=>pe,swapRecords:()=>Pr,uniqBy:()=>lt,useDataTableColumns:()=>ln});module.exports=Cr(xr);var Bt=require("@mantine/core"),Wt=require("@mantine/hooks"),Tn=q(require("clsx")),J=require("react");var xn=require("@mantine/core");function V(e,t,n){return e?(0,xn.parseThemeColor)({color:typeof e=="object"?e[n]:e,theme:t}).value:void 0}function vn({theme:e,c:t,backgroundColor:n,borderColor:o,rowBorderColor:a,stripedColor:r,highlightOnHoverColor:l}){return{"--mantine-datatable-color-light":V(t,e,"light"),"--mantine-datatable-color-dark":V(t,e,"dark"),"--mantine-datatable-background-color-light":V(n,e,"light"),"--mantine-datatable-background-color-dark":V(n,e,"dark"),"--mantine-datatable-border-color-light":V(o,e,"light"),"--mantine-datatable-border-color-dark":V(o,e,"dark"),"--mantine-datatable-row-border-color-light":V(a,e,"light"),"--mantine-datatable-row-border-color-dark":V(a,e,"dark"),"--mantine-datatable-striped-color-light":V(r,e,"light"),"--mantine-datatable-striped-color-dark":V(r,e,"dark"),"--mantine-datatable-highlight-on-hover-color-light":V(l,e,"light"),"--mantine-datatable-highlight-on-hover-color-dark":V(l,e,"dark")}}function wt({theme:e,paginationActiveTextColor:t,paginationActiveBackgroundColor:n}){return{"--mantine-datatable-pagination-active-text-color-light":V(t,e,"light"),"--mantine-datatable-pagination-active-text-color-dark":V(t,e,"dark"),"--mantine-datatable-pagination-active-background-color-light":V(n,e,"light"),"--mantine-datatable-pagination-active-background-color-dark":V(n,e,"dark")}}function Mn({theme:e,color:t,backgroundColor:n}){return{"--mantine-datatable-row-color-light":V(t,e,"light"),"--mantine-datatable-row-color-dark":V(t,e,"dark"),"--mantine-datatable-row-background-color-light":V(n,e,"light"),"--mantine-datatable-row-background-color-dark":V(n,e,"dark")}}var en=require("react");var kn=require("@mantine/core"),[En,$e]=(0,kn.createSafeContext)("useDataTableColumnsContext must be used within DataTableColumnProvider");var Ln=require("react/jsx-runtime"),tn=e=>{let{children:t,columnsOrder:n,setColumnsOrder:o,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S,pinnedMap:b}=e,[C,E]=(0,en.useState)(""),[D,L]=(0,en.useState)("");return(0,Ln.jsx)(En,{value:{sourceColumn:C,setSourceColumn:E,targetColumn:D,setTargetColumn:L,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,swapColumns:()=>{if(!n||!o||!C||!D)return;let f=b.get(C),h=b.get(D);if(f?.logicalSide!==h?.logicalSide)return;let w=n.indexOf(C),M=n.indexOf(D);if(w!==-1&&M!==-1){let g=[...n],[R]=g.splice(w,1);g.splice(M,0,R),o(g)}},resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S},children:t})};var nn=require("react/jsx-runtime");function In(){return(0,nn.jsx)("tr",{className:"mantine-datatable-empty-row",children:(0,nn.jsx)("td",{})})}var St=require("@mantine/core");var be=require("react/jsx-runtime");function Nn(){return(0,be.jsxs)("svg",{width:"24",height:"24",viewBox:"0 0 24 24",strokeWidth:"2",stroke:"currentColor",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[(0,be.jsx)("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),(0,be.jsx)("path",{d:"M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74"}),(0,be.jsx)("path",{d:"M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6"}),(0,be.jsx)("path",{d:"M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4"}),(0,be.jsx)("path",{d:"M3 3l18 18"})]})}var me=require("react/jsx-runtime");function Hn({icon:e,text:t,active:n,children:o}){return(0,me.jsx)(St.Center,{className:"mantine-datatable-empty-state","data-active":n||void 0,children:o||(0,me.jsxs)(me.Fragment Line truncated
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/dist/index.mjs b/dist/index.mjs
index bfbf1cdbdd176b5776f553075da709115855384a..9583f1c5db65568d77f2dc468ab2d9841175623b 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1,2 +1,2 @@
-"use client";import{Box as Wl,Table as Bl}from"@mantine/core";import{useMergedRef as Ht}from"@mantine/hooks";import Co from"clsx";import{useCallback as At,useEffect as Vl,useLayoutEffect as Gl,useMemo as Fl,useRef as lt}from"react";import{parseThemeColor as Sa}from"@mantine/core";function W(e,t,n){return e?Sa({color:typeof e=="object"?e[n]:e,theme:t}).value:void 0}function Ft({theme:e,c:t,backgroundColor:n,borderColor:o,rowBorderColor:a,stripedColor:r,highlightOnHoverColor:l}){return{"--mantine-datatable-color-light":W(t,e,"light"),"--mantine-datatable-color-dark":W(t,e,"dark"),"--mantine-datatable-background-color-light":W(n,e,"light"),"--mantine-datatable-background-color-dark":W(n,e,"dark"),"--mantine-datatable-border-color-light":W(o,e,"light"),"--mantine-datatable-border-color-dark":W(o,e,"dark"),"--mantine-datatable-row-border-color-light":W(a,e,"light"),"--mantine-datatable-row-border-color-dark":W(a,e,"dark"),"--mantine-datatable-striped-color-light":W(r,e,"light"),"--mantine-datatable-striped-color-dark":W(r,e,"dark"),"--mantine-datatable-highlight-on-hover-color-light":W(l,e,"light"),"--mantine-datatable-highlight-on-hover-color-dark":W(l,e,"dark")}}function We({theme:e,paginationActiveTextColor:t,paginationActiveBackgroundColor:n}){return{"--mantine-datatable-pagination-active-text-color-light":W(t,e,"light"),"--mantine-datatable-pagination-active-text-color-dark":W(t,e,"dark"),"--mantine-datatable-pagination-active-background-color-light":W(n,e,"light"),"--mantine-datatable-pagination-active-background-color-dark":W(n,e,"dark")}}function $t({theme:e,color:t,backgroundColor:n}){return{"--mantine-datatable-row-color-light":W(t,e,"light"),"--mantine-datatable-row-color-dark":W(t,e,"dark"),"--mantine-datatable-row-background-color-light":W(n,e,"light"),"--mantine-datatable-row-background-color-dark":W(n,e,"dark")}}import{useState as Ut}from"react";import{createSafeContext as Ra}from"@mantine/core";var[Xt,Pe]=Ra("useDataTableColumnsContext must be used within DataTableColumnProvider");import{jsx as xa}from"react/jsx-runtime";var Ct=e=>{let{children:t,columnsOrder:n,setColumnsOrder:o,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S,pinnedMap:b}=e,[C,E]=Ut(""),[D,L]=Ut("");return xa(Xt,{value:{sourceColumn:C,setSourceColumn:E,targetColumn:D,setTargetColumn:L,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,swapColumns:()=>{if(!n||!o||!C||!D)return;let f=b.get(C),h=b.get(D);if(f?.logicalSide!==h?.logicalSide)return;let w=n.indexOf(C),M=n.indexOf(D);if(w!==-1&&M!==-1){let g=[...n],[R]=g.splice(w,1);g.splice(M,0,R),o(g)}},resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S},children:t})};import{jsx as Qt}from"react/jsx-runtime";function Kt(){return Qt("tr",{className:"mantine-datatable-empty-row",children:Qt("td",{})})}import{Center as Ma,Text as ka}from"@mantine/core";import{jsx as Le,jsxs as va}from"react/jsx-runtime";function jt(){return va("svg",{width:"24",height:"24",viewBox:"0 0 24 24",strokeWidth:"2",stroke:"currentColor",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[Le("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),Le("path",{d:"M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74"}),Le("path",{d:"M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6"}),Le("path",{d:"M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4"}),Le("path",{d:"M3 3l18 18"})]})}import{Fragment as Ea,jsx as Be,jsxs as La}from"react/jsx-runtime";function Jt({icon:e,text:t,active:n,children:o}){return Be(Ma,{className:"mantine-datatable-empty-state","data-active":n||void 0,children:o||La(Ea,{children:[e||Be("div",{className:"mantine-datatable-empty-state-icon",children:Be(jt,{})}),Be(ka,{component:"div",size:"sm",c:"dimmed",children:t})]})})}import{TableTfoot as dr,TableTr as mr}from"@mantine/core";import pr from"clsx";import{TableTh as lr}from"@mantine/core";import ir from"clsx";import{useCallback as fi,useRef as bi,useState as gi}from"react";import{useLocalStorage as Ia}from"@mantine/hooks";import{useEffect as Na,useMemo as Yt}from"react";function De(e){let t=e.replace(/([a-z\d])([A-Z]+)/g,"$1 $2").replace(/\W|_/g," ").trim().toLowerCase();return`${t.charAt(0).toUpperCase()}${t.slice(1)}`}function Z(e,t,n){return!Array.isArray(e)||n&&!e.every(n)?t:e}function Ve(e){return typeof e=="object"&&e!==null&&typeof e.accessor=="string"}function qt(e,t,n){return e.filter(o=>!t.map(n).includes(n(o)))}function Ge(e,t){return e.filter((n,o,a)=>o===a.findIndex(r=>t(n)===t(r)))}function yt(e,t){return t?t.match(/ Line truncated
+"use client";import{Box as Wl,Table as Bl}from"@mantine/core";import{useMergedRef as Ht}from"@mantine/hooks";import Co from"clsx";import{useCallback as At,useEffect as Vl,useLayoutEffect as Gl,useMemo as Fl,useRef as lt}from"react";import{parseThemeColor as Sa}from"@mantine/core";function W(e,t,n){return e?Sa({color:typeof e=="object"?e[n]:e,theme:t}).value:void 0}function Ft({theme:e,c:t,backgroundColor:n,borderColor:o,rowBorderColor:a,stripedColor:r,highlightOnHoverColor:l}){return{"--mantine-datatable-color-light":W(t,e,"light"),"--mantine-datatable-color-dark":W(t,e,"dark"),"--mantine-datatable-background-color-light":W(n,e,"light"),"--mantine-datatable-background-color-dark":W(n,e,"dark"),"--mantine-datatable-border-color-light":W(o,e,"light"),"--mantine-datatable-border-color-dark":W(o,e,"dark"),"--mantine-datatable-row-border-color-light":W(a,e,"light"),"--mantine-datatable-row-border-color-dark":W(a,e,"dark"),"--mantine-datatable-striped-color-light":W(r,e,"light"),"--mantine-datatable-striped-color-dark":W(r,e,"dark"),"--mantine-datatable-highlight-on-hover-color-light":W(l,e,"light"),"--mantine-datatable-highlight-on-hover-color-dark":W(l,e,"dark")}}function We({theme:e,paginationActiveTextColor:t,paginationActiveBackgroundColor:n}){return{"--mantine-datatable-pagination-active-text-color-light":W(t,e,"light"),"--mantine-datatable-pagination-active-text-color-dark":W(t,e,"dark"),"--mantine-datatable-pagination-active-background-color-light":W(n,e,"light"),"--mantine-datatable-pagination-active-background-color-dark":W(n,e,"dark")}}function $t({theme:e,color:t,backgroundColor:n}){return{"--mantine-datatable-row-color-light":W(t,e,"light"),"--mantine-datatable-row-color-dark":W(t,e,"dark"),"--mantine-datatable-row-background-color-light":W(n,e,"light"),"--mantine-datatable-row-background-color-dark":W(n,e,"dark")}}import{useState as Ut}from"react";import{createSafeContext as Ra}from"@mantine/core";var[Xt,Pe]=Ra("useDataTableColumnsContext must be used within DataTableColumnProvider");import{jsx as xa}from"react/jsx-runtime";var Ct=e=>{let{children:t,columnsOrder:n,setColumnsOrder:o,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S,pinnedMap:b}=e,[C,E]=Ut(""),[D,L]=Ut("");return xa(Xt,{value:{sourceColumn:C,setSourceColumn:E,targetColumn:D,setTargetColumn:L,columnsToggle:a,setColumnsToggle:r,columnsPinning:l,setColumnsPinning:s,swapColumns:()=>{if(!n||!o||!C||!D)return;let f=b.get(C),h=b.get(D);if(f?.logicalSide!==h?.logicalSide)return;let w=n.indexOf(C),M=n.indexOf(D);if(w!==-1&&M!==-1){let g=[...n],[R]=g.splice(w,1);g.splice(M,0,R),o(g)}},resetColumnsOrder:c,resetColumnsToggle:m,resetColumnsPinning:i,setColumnWidth:u,setMultipleColumnWidths:p,resetColumnsWidth:T,beginResize:d,endResize:S},children:t})};import{jsx as Qt}from"react/jsx-runtime";function Kt(){return Qt("tr",{className:"mantine-datatable-empty-row",children:Qt("td",{})})}import{Center as Ma,Text as ka}from"@mantine/core";import{jsx as Le,jsxs as va}from"react/jsx-runtime";function jt(){return va("svg",{width:"24",height:"24",viewBox:"0 0 24 24",strokeWidth:"2",stroke:"currentColor",fill:"none",strokeLinecap:"round",strokeLinejoin:"round",children:[Le("path",{stroke:"none",d:"M0 0h24v24H0z",fill:"none"}),Le("path",{d:"M12.983 8.978c3.955 -.182 7.017 -1.446 7.017 -2.978c0 -1.657 -3.582 -3 -8 -3c-1.661 0 -3.204 .19 -4.483 .515m-2.783 1.228c-.471 .382 -.734 .808 -.734 1.257c0 1.22 1.944 2.271 4.734 2.74"}),Le("path",{d:"M4 6v6c0 1.657 3.582 3 8 3c.986 0 1.93 -.067 2.802 -.19m3.187 -.82c1.251 -.53 2.011 -1.228 2.011 -1.99v-6"}),Le("path",{d:"M4 12v6c0 1.657 3.582 3 8 3c3.217 0 5.991 -.712 7.261 -1.74m.739 -3.26v-4"}),Le("path",{d:"M3 3l18 18"})]})}import{Fragment as Ea,jsx as Be,jsxs as La}from"react/jsx-runtime";function Jt({icon:e,text:t,active:n,children:o}){return Be(Ma,{className:"mantine-datatable-empty-state","data-active":n||void 0,children:o||La(Ea,{children:[e||Be("div",{className:"mantine-datatable-empty-state-icon",children:Be(jt,{})}),Be(ka,{component:"div",size:"sm",c:"dimmed",children:t})]})})}import{TableTfoot as dr,TableTr as mr}from"@mantine/core";import pr from"clsx";import{TableTh as lr}from"@mantine/core";import ir from"clsx";import{useCallback as fi,useRef as bi,useState as gi}from"react";import{useLocalStorage as Ia}from"@mantine/hooks";import{useEffect as Na,useMemo as Yt}from"react";function De(e){let t=e.replace(/([a-z\d])([A-Z]+)/g,"$1 $2").replace(/\W|_/g," ").trim().toLowerCase();return`${t.charAt(0).toUpperCase()}${t.slice(1)}`}function Z(e,t,n){return!Array.isArray(e)||n&&!e.every(n)?t:e}function Ve(e){return typeof e=="object"&&e!==null&&typeof e.accessor=="string"}function qt(e,t,n){return e.filter(o=>!t.map(n).includes(n(o)))}function Ge(e,t){return e.filter((n,o,a)=>o===a.findIndex(r=>t(n)===t(r)))}function yt(e,t){return t?t.match(/ Line truncated
//# sourceMappingURL=index.mjs.map
+4 -3
View File
@@ -704,6 +704,7 @@ overrides:
patchedDependencies:
'@types/node-unifi': 5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5
mantine-datatable@9.3.1: 8b83a773610215e619e3f5acbec2d26fb6f27b089852e2bc754d10d8515c1254
trpc-to-openapi: 2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c
importers:
@@ -1209,7 +1210,7 @@ importers:
version: 3.10.1
mantine-datatable:
specifier: 'catalog:'
version: 9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.3.1(patch_hash=8b83a773610215e619e3f5acbec2d26fb6f27b089852e2bc754d10d8515c1254)(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2984,7 +2985,7 @@ importers:
version: 1.24.0(react@19.2.7)
mantine-datatable:
specifier: 'catalog:'
version: 9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
version: 9.3.1(patch_hash=8b83a773610215e619e3f5acbec2d26fb6f27b089852e2bc754d10d8515c1254)(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mantine-react-table:
specifier: 'catalog:'
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -28633,7 +28634,7 @@ snapshots:
dependencies:
semver: 7.8.5
mantine-datatable@9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
mantine-datatable@9.3.1(patch_hash=8b83a773610215e619e3f5acbec2d26fb6f27b089852e2bc754d10d8515c1254)(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@mantine/core': 9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.4.1(react@19.2.7)
+1
View File
@@ -280,6 +280,7 @@ overrides:
"vite@>=5.0.0 <=5.4.18": ">=8.1.4"
patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch"
mantine-datatable@9.3.1: patches/mantine-datatable@9.3.1.patch
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
allowUnusedPatches: true