Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b6f08a36 | ||
|
|
5cd627d66c | ||
|
|
002381d294 | ||
|
|
9b406d5d83 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 |
No files matched your search
@@ -374,21 +374,31 @@ jobs:
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
|
||||
- name: Verify preview manifest platforms
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
|
||||
@@ -27,37 +27,103 @@ jobs:
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v6
|
||||
id: metadata
|
||||
with:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-amd64
|
||||
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
|
||||
|
||||
publish-arm64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/arm64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-arm64
|
||||
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: [publish-amd64, publish-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish multi-platform image
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}")
|
||||
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:v2")
|
||||
fi
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:latest")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}"
|
||||
- name: Verify multi-platform image
|
||||
run: |
|
||||
image="${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
||||
|
||||
return (
|
||||
<AccountShell title={t("title")} description={t("description")}>
|
||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
||||
{submitError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
||||
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -123,19 +123,12 @@ const MIN_CELL_HEIGHT = 80;
|
||||
const ADVANCED_MIN_CELL_WIDTH = 320;
|
||||
const ADVANCED_MIN_CELL_HEIGHT = 420;
|
||||
|
||||
const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
const emptyCells = (c - (itemCount % c)) % c;
|
||||
const bestEmpty = (best - (itemCount % best)) % best;
|
||||
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
|
||||
const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio));
|
||||
return Math.min(maxCols, Math.max(1, idealCols));
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -26,6 +26,7 @@ const columnTranslationKeyMap = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||
icon: IconBrandDocker,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["docker", "getContainers"]],
|
||||
refetchInterval: 30,
|
||||
createOptions() {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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
|
||||
Generated
+4
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user