feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -6,6 +6,7 @@ import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@ho
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
|
||||
import {
|
||||
getBoardLaneColumnCount,
|
||||
getInitialBoardLogicalHeight,
|
||||
@@ -56,66 +57,68 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
.focusScope {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 198;
|
||||
background: rgba(0, 0, 0, 0.64);
|
||||
backdrop-filter: blur(6px) saturate(0.75);
|
||||
touch-action: none;
|
||||
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.surface {
|
||||
position: fixed;
|
||||
z-index: 199;
|
||||
pointer-events: auto;
|
||||
overflow: hidden;
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
overscroll-behavior: contain;
|
||||
animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.backdropClosing {
|
||||
animation: focus-backdrop-out 180ms ease-in forwards;
|
||||
}
|
||||
|
||||
.surfaceClosing {
|
||||
animation: focus-surface-out 180ms ease-in forwards;
|
||||
}
|
||||
|
||||
.sourcePlaceholder {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surfaceHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.surfaceHostAdvanced {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 199;
|
||||
width: auto;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.surfaceControls {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pinButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 52px;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.touchButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 2;
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.touchButton {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-surface-in {
|
||||
from {
|
||||
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
|
||||
scale(var(--focus-scale-x), var(--focus-scale-y));
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-backdrop-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focus-surface-out {
|
||||
to {
|
||||
transform: translate(var(--focus-translate-x), var(--focus-translate-y))
|
||||
scale(var(--focus-scale-x), var(--focus-scale-y));
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop,
|
||||
.surface {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.backdrop {
|
||||
backdrop-filter: none;
|
||||
background: Canvas;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Portal, RemoveScroll } from "@mantine/core";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import classes from "./advanced-focus.module.css";
|
||||
import { isEscapeOwnedByNestedOverlay } from "./escape";
|
||||
import type { FocusRect } from "./geometry";
|
||||
|
||||
interface ActiveFocus {
|
||||
itemId: string;
|
||||
source: HTMLElement;
|
||||
sourceRect: FocusRect;
|
||||
pinned: boolean;
|
||||
autofocusClose: boolean;
|
||||
phase: "visible" | "closing";
|
||||
}
|
||||
|
||||
interface AdvancedFocusContextValue {
|
||||
active: ActiveFocus | null;
|
||||
open: (itemId: string, source: HTMLElement, pinned?: boolean) => void;
|
||||
close: (restoreFocus?: boolean) => void;
|
||||
dismiss: (itemId: string) => void;
|
||||
pin: (autofocusClose?: boolean) => void;
|
||||
hover: (itemId: string, source: HTMLElement) => void;
|
||||
leave: (itemId: string) => void;
|
||||
}
|
||||
|
||||
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(null);
|
||||
const HOVER_DELAY_MS = 150;
|
||||
const CLOSE_DURATION_MS = 180;
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) =>
|
||||
target instanceof HTMLElement &&
|
||||
(target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable);
|
||||
|
||||
export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const [active, setActive] = useState<ActiveFocus | null>(null);
|
||||
const activeRef = useRef<ActiveFocus | null>(null);
|
||||
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
|
||||
const shiftHeldRef = useRef(false);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelHoverTimer = useCallback(() => {
|
||||
if (hoverTimerRef.current === null) return;
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const updateActive = useCallback((next: ActiveFocus | null) => {
|
||||
activeRef.current = next;
|
||||
setActive(next);
|
||||
}, []);
|
||||
|
||||
const cancelCloseTimer = useCallback(() => {
|
||||
if (closeTimerRef.current === null) return;
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(itemId: string, source: HTMLElement, pinned = false) => {
|
||||
if (isEditMode) return;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
const rect = source.getBoundingClientRect();
|
||||
updateActive({
|
||||
itemId,
|
||||
source,
|
||||
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
||||
pinned,
|
||||
autofocusClose: pinned,
|
||||
phase: "visible",
|
||||
});
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive],
|
||||
);
|
||||
|
||||
const close = useCallback(
|
||||
(restoreFocus = true) => {
|
||||
cancelHoverTimer();
|
||||
const current = activeRef.current;
|
||||
if (!current || current.phase === "closing") return;
|
||||
updateActive({ ...current, phase: "closing" });
|
||||
const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS;
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
closeTimerRef.current = null;
|
||||
updateActive(null);
|
||||
if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
|
||||
}, closeDuration);
|
||||
},
|
||||
[cancelHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
const startHoverTimer = useCallback(() => {
|
||||
cancelHoverTimer();
|
||||
const hovered = hoveredRef.current;
|
||||
if (!hovered || !shiftHeldRef.current || activeRef.current) return;
|
||||
hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS);
|
||||
}, [cancelHoverTimer, open]);
|
||||
|
||||
const hover = useCallback(
|
||||
(itemId: string, source: HTMLElement) => {
|
||||
hoveredRef.current = { itemId, source };
|
||||
startHoverTimer();
|
||||
},
|
||||
[startHoverTimer],
|
||||
);
|
||||
|
||||
const leave = useCallback(
|
||||
(itemId: string) => {
|
||||
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
|
||||
cancelHoverTimer();
|
||||
},
|
||||
[cancelHoverTimer],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(itemId: string) => {
|
||||
if (activeRef.current?.itemId !== itemId) return;
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
updateActive(null);
|
||||
},
|
||||
[cancelCloseTimer, cancelHoverTimer, updateActive],
|
||||
);
|
||||
|
||||
const pin = useCallback(
|
||||
(autofocusClose = false) => {
|
||||
const current = activeRef.current;
|
||||
if (!current || current.pinned || current.phase === "closing") return;
|
||||
updateActive({ ...current, pinned: true, autofocusClose });
|
||||
},
|
||||
[updateActive],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && activeRef.current) {
|
||||
const advancedSurface = document.querySelector(`.${classes.surface}`);
|
||||
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
|
||||
return;
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (isEditMode) return;
|
||||
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
|
||||
if (activeRef.current?.pinned) return;
|
||||
shiftHeldRef.current = true;
|
||||
startHoverTimer();
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Shift") return;
|
||||
shiftHeldRef.current = false;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current && !activeRef.current.pinned) close(false);
|
||||
};
|
||||
const handleBlur = () => {
|
||||
shiftHeldRef.current = false;
|
||||
cancelHoverTimer();
|
||||
if (activeRef.current && !activeRef.current.pinned) close(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
cancelHoverTimer();
|
||||
cancelCloseTimer();
|
||||
};
|
||||
}, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && activeRef.current) close(false);
|
||||
}, [close, isEditMode]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ active, open, close, dismiss, pin, hover, leave }),
|
||||
[active, close, dismiss, hover, leave, open, pin],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdvancedFocusContext.Provider value={value}>
|
||||
<RemoveScroll enabled={active !== null} className={classes.focusScope}>
|
||||
{children}
|
||||
</RemoveScroll>
|
||||
{active && (
|
||||
<Portal>
|
||||
<Box
|
||||
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
|
||||
onPointerDown={() => close()}
|
||||
aria-hidden
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</AdvancedFocusContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAdvancedFocus = () => {
|
||||
const context = useContext(AdvancedFocusContext);
|
||||
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { isEscapeOwnedByNestedOverlay } from "./escape";
|
||||
|
||||
describe("isEscapeOwnedByNestedOverlay", () => {
|
||||
test("allows Escape from the advanced surface itself", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const button = document.createElement("button");
|
||||
surface.append(button);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false);
|
||||
});
|
||||
|
||||
test("leaves Escape to a nested Mantine modal", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const modal = document.createElement("section");
|
||||
modal.setAttribute("role", "dialog");
|
||||
const input = document.createElement("input");
|
||||
modal.append(input);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const overlay = document.createElement("div");
|
||||
overlay.setAttribute("role", role);
|
||||
const target = document.createElement("button");
|
||||
overlay.append(target);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const target = document.createElement("button");
|
||||
target.setAttribute("data-mantine-stop-propagation", "true");
|
||||
surface.append(target);
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not mistake the compact widget source for a nested overlay", () => {
|
||||
const surface = document.createElement("section");
|
||||
surface.setAttribute("role", "dialog");
|
||||
const source = document.createElement("button");
|
||||
|
||||
expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
const escapeOwnerSelector = [
|
||||
'[data-mantine-stop-propagation="true"]',
|
||||
'[role="dialog"]',
|
||||
'[role="menu"]',
|
||||
'[role="listbox"]',
|
||||
].join(", ");
|
||||
|
||||
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
|
||||
const escapeOwner = target.closest(escapeOwnerSelector);
|
||||
return escapeOwner !== null && escapeOwner !== advancedSurface;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdvancedFocusRect } from "./geometry";
|
||||
|
||||
describe("getAdvancedFocusRect", () => {
|
||||
it("expands a widget and keeps it inside the viewport", () => {
|
||||
expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({
|
||||
left: 240,
|
||||
top: 190,
|
||||
width: 720,
|
||||
height: 480,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the available viewport on small screens", () => {
|
||||
expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({
|
||||
left: 8,
|
||||
top: 68,
|
||||
width: 374,
|
||||
height: 624,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps controls below the fixed board header on short screens", () => {
|
||||
const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 });
|
||||
|
||||
expect(rect.top).toBeGreaterThanOrEqual(84);
|
||||
expect(rect.top + rect.height).toBeLessThanOrEqual(744);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { appShellHeaderHeight } from "../../layout/constants";
|
||||
|
||||
export interface FocusRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
|
||||
const margin = viewport.width < 640 ? 8 : 24;
|
||||
const availableWidth = Math.max(0, viewport.width - margin * 2);
|
||||
const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
|
||||
const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4)));
|
||||
const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4)));
|
||||
|
||||
return {
|
||||
left: Math.max(margin, (viewport.width - width) / 2),
|
||||
top: Math.max(
|
||||
appShellHeaderHeight + margin,
|
||||
appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2,
|
||||
),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "../empty-position";
|
||||
import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
|
||||
|
||||
describe("get first empty position", () => {
|
||||
test.each([
|
||||
@@ -106,6 +106,37 @@ describe("get first empty position", () => {
|
||||
|
||||
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||
});
|
||||
|
||||
test("does not place a wide item beyond the right edge", () => {
|
||||
const elements = createElementsFromLayout([["a", "a", "a", " "]]);
|
||||
|
||||
expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get nearest empty position", () => {
|
||||
test("keeps the preferred cell when it fits", () => {
|
||||
expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("chooses the nearest available cell when the preferred cell is occupied", () => {
|
||||
const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
|
||||
|
||||
expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
|
||||
xOffset: 2,
|
||||
yOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns undefined when the item cannot fit within a bounded section", () => {
|
||||
expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from "react";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import type { CreateItemInput } from "./actions/create-item";
|
||||
import { createItemCallback } from "./actions/create-item";
|
||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||
@@ -34,7 +35,12 @@ export const useItemActions = () => {
|
||||
|
||||
const createItem = useCallback(
|
||||
(input: CreateItemInput) => {
|
||||
updateBoard(createItemCallback(input));
|
||||
let nextBoard: Board | undefined;
|
||||
updateBoard((previous) => {
|
||||
nextBoard = createItemCallback(input)(previous);
|
||||
return nextBoard;
|
||||
});
|
||||
return nextBoard;
|
||||
},
|
||||
[updateBoard],
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ComponentType, MutableRefObject } from "react";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
|
||||
import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
|
||||
import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
@@ -13,12 +14,23 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import {
|
||||
createWidgetRuntimeState,
|
||||
getWidgetQueryKeys,
|
||||
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
|
||||
} from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
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 classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
@@ -92,38 +104,305 @@ const WidgetDefinitionLoadError = ({
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
|
||||
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
contentRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
contentRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({
|
||||
item,
|
||||
width,
|
||||
height,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
contentRef,
|
||||
}: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const sourceRef = useRef<HTMLDivElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const previewEntranceFrameRef = useRef<number | null>(null);
|
||||
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
|
||||
const board = useRequiredBoard();
|
||||
const t = useI18n();
|
||||
const [isEditMode] = useEditMode();
|
||||
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
|
||||
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
|
||||
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
|
||||
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
|
||||
const widgetName = t(`widget.${item.kind}.name`);
|
||||
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
|
||||
const advancedViewId = `advanced-focus-${item.id}`;
|
||||
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
|
||||
const isAdvanced = activeFocus !== null;
|
||||
const advancedRect = activeFocus
|
||||
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
|
||||
: null;
|
||||
const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null;
|
||||
const isPreview = activeFocus?.activation === "preview";
|
||||
const isManual = activeFocus?.activation === "manual";
|
||||
|
||||
useIsomorphicEffect(() => {
|
||||
if (!supportsAdvancedFocus) return;
|
||||
const target = document.createElement("div");
|
||||
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
|
||||
setSurfacePortalTarget(target);
|
||||
return () => target.remove();
|
||||
}, [supportsAdvancedFocus]);
|
||||
|
||||
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.currentTarget !== event.target ||
|
||||
event.key !== "Enter" ||
|
||||
!event.shiftKey ||
|
||||
isEditMode ||
|
||||
!supportsAdvancedFocus
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const surface = isManual ? manualSurface : cardRef.current;
|
||||
if (!isAdvanced || !surface) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.shiftKey) return;
|
||||
const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1;
|
||||
const delta = (event.deltaY || event.deltaX) * multiplier;
|
||||
if (!redirectShiftWheel(surface, event.target, delta)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
surface.addEventListener("wheel", handleWheel, { capture: true, passive: false });
|
||||
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
|
||||
}, [isAdvanced, isManual, manualSurface]);
|
||||
|
||||
const cancelPreviewEntrance = useCallback(() => {
|
||||
if (previewEntranceFrameRef.current === null) return;
|
||||
cancelAnimationFrame(previewEntranceFrameRef.current);
|
||||
previewEntranceFrameRef.current = null;
|
||||
}, []);
|
||||
|
||||
const mountCompactHost = useCallback(
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || isAdvanced) return;
|
||||
cancelPreviewEntrance();
|
||||
host.append(surfacePortalTarget);
|
||||
},
|
||||
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
|
||||
);
|
||||
|
||||
const mountPreviewHost = useCallback(
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || !isPreview) {
|
||||
if (!host) cancelPreviewEntrance();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeFocus?.phase !== "visible") {
|
||||
host.append(surfacePortalTarget);
|
||||
return;
|
||||
}
|
||||
const card = cardRef.current;
|
||||
const readyClass = advancedFocusClasses.surfaceReady;
|
||||
if (!card || !readyClass) return;
|
||||
|
||||
cancelPreviewEntrance();
|
||||
previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass);
|
||||
},
|
||||
[activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget],
|
||||
);
|
||||
|
||||
const mountManualHost = useCallback(
|
||||
(host: HTMLDivElement | null) => {
|
||||
if (!host || !surfacePortalTarget || !isManual) return;
|
||||
cancelPreviewEntrance();
|
||||
host.append(surfacePortalTarget);
|
||||
},
|
||||
[cancelPreviewEntrance, isManual, surfacePortalTarget],
|
||||
);
|
||||
|
||||
useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]);
|
||||
|
||||
const previewStyle =
|
||||
isPreview && advancedRect && activeFocus
|
||||
? ({
|
||||
left: advancedRect.left,
|
||||
top: advancedRect.top,
|
||||
width: advancedRect.width,
|
||||
height: advancedRect.height,
|
||||
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
|
||||
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
|
||||
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
|
||||
"--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const widgetCard = (
|
||||
<Card
|
||||
ref={cardRef}
|
||||
id={isPreview ? advancedViewId : undefined}
|
||||
role={isPreview ? "region" : undefined}
|
||||
aria-label={isPreview ? advancedViewLabel : undefined}
|
||||
data-advanced-focus-surface={isPreview || undefined}
|
||||
data-grid-item-content
|
||||
radius={board.itemRadius}
|
||||
p={isAdvanced ? undefined : 0}
|
||||
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
isPreview && advancedFocusClasses.surface,
|
||||
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
style={previewStyle}
|
||||
>
|
||||
<Box ref={contentRef} w="100%" h="100%" mih={0}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<LoadedBoardItemContent
|
||||
<InnerContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
innerRef={ref}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
displayMode={isAdvanced ? "advanced" : "compact"}
|
||||
definition={definition}
|
||||
Component={Component}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu
|
||||
item={item}
|
||||
definition={definition}
|
||||
widgetStateRef={widgetStateRef}
|
||||
widgetRuntimeRef={widgetRuntimeRef}
|
||||
sourceRef={sourceRef}
|
||||
>
|
||||
<Box
|
||||
ref={sourceRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-advanced-focus-source
|
||||
data-item-id={item.id}
|
||||
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
|
||||
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
|
||||
aria-label={
|
||||
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
|
||||
}
|
||||
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
|
||||
aria-controls={isAdvanced ? advancedViewId : undefined}
|
||||
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
|
||||
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
|
||||
onPointerEnter={() => {
|
||||
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (supportsAdvancedFocus) leave(item.id);
|
||||
}}
|
||||
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
|
||||
>
|
||||
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
|
||||
{!supportsAdvancedFocus && widgetCard}
|
||||
</div>
|
||||
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
|
||||
{isPreview && (
|
||||
<Portal reuseTargetNode={false}>
|
||||
<div ref={mountPreviewHost} className={advancedFocusClasses.surfaceHostAdvanced} />
|
||||
</Portal>
|
||||
)}
|
||||
{isManual && activeFocus && advancedRect && closePosition && (
|
||||
<AdvancedFocusManualSurface
|
||||
opened={activeFocus.phase === "visible"}
|
||||
phase={activeFocus.phase}
|
||||
id={advancedViewId}
|
||||
label={advancedViewLabel}
|
||||
closeLabel={t("item.advancedFocus.close")}
|
||||
rect={advancedRect}
|
||||
closePosition={closePosition}
|
||||
sourceRect={activeFocus.sourceRect}
|
||||
radius={board.itemRadius}
|
||||
contentRef={setManualSurface}
|
||||
onClose={() => close()}
|
||||
>
|
||||
<div ref={mountManualHost} className={advancedFocusClasses.manualSurfaceMount} />
|
||||
</AdvancedFocusManualSurface>
|
||||
)}
|
||||
</Box>
|
||||
</WidgetContextMenu>
|
||||
{!isAdvanced && item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
// It's 4 because of the mantine-react-table that has z-index 3
|
||||
style={{ zIndex: 4 }}
|
||||
style={{ zIndex: "var(--mantine-z-index-app)" }}
|
||||
top={2}
|
||||
left={16}
|
||||
size="xs"
|
||||
@@ -144,49 +423,18 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
|
||||
</ErrorBoundary>
|
||||
</BoardItemCard>
|
||||
</WidgetContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
displayMode: "compact" | "advanced";
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -260,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
||||
if (when) throw error;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
|
||||
@@ -290,7 +290,6 @@ const ItemSelectModalContent = ({
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
|
||||
@@ -1,149 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import type { MutableRefObject, ReactNode, RefObject } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react";
|
||||
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
|
||||
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
|
||||
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import { useAdvancedFocus } from "../advanced-focus/context";
|
||||
import { useBoardPermissions } from "../permissions/client";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
interface WidgetContextMenuProps {
|
||||
item: SectionItem;
|
||||
definition: WidgetDefinition;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
widgetRuntimeRef: WidgetRuntimeRef;
|
||||
sourceRef: RefObject<HTMLElement | null>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const settings = useSettings();
|
||||
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
|
||||
return (
|
||||
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
|
||||
{children}
|
||||
</WidgetContextMenuInner>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetContextMenuInner = ({
|
||||
export const WidgetContextMenu = ({
|
||||
item,
|
||||
definition,
|
||||
widgetStateRef,
|
||||
widgetRuntimeRef,
|
||||
sourceRef,
|
||||
children,
|
||||
settings,
|
||||
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={300}
|
||||
closeOnItemClick={false}
|
||||
position="right-start"
|
||||
offset={4}
|
||||
opened={isMenuOpen}
|
||||
onChange={setIsMenuOpen}
|
||||
>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<Menu.Dropdown>
|
||||
{isMenuOpen && (
|
||||
<WidgetContextMenuDropdown
|
||||
item={item}
|
||||
definition={definition}
|
||||
widgetStateRef={widgetStateRef}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
|
||||
settings: ReturnType<typeof useSettings>;
|
||||
};
|
||||
|
||||
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
|
||||
}: WidgetContextMenuProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { updateAndPersistBoard } = usePersistBoard(board);
|
||||
const tMenu = useScopedI18n("item.menu.label");
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
|
||||
const hasSupportedIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const settings = useSettings();
|
||||
const { openModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
|
||||
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: hasSupportedIntegrations,
|
||||
});
|
||||
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
|
||||
const canConfigureWidget =
|
||||
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
|
||||
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
|
||||
const queryClient = useQueryClient();
|
||||
const { open: openAdvancedFocus } = useAdvancedFocus();
|
||||
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
|
||||
const [menuOpened, setMenuOpened] = useState(false);
|
||||
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const isWidgetFetching =
|
||||
useIsFetching({
|
||||
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
|
||||
}) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" })));
|
||||
}, [queryClient, widgetQueryKeys]);
|
||||
const persistBoard = useCallback(
|
||||
(updater: (previous: typeof board) => typeof board) => {
|
||||
updateAndPersistBoard(updater, {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: t("item.menu.notification.saveError.title"),
|
||||
message: t("item.menu.notification.saveError.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[t, updateAndPersistBoard],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
|
||||
[definition, settings, item.options],
|
||||
);
|
||||
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
|
||||
const matchesWidgetQuery = useCallback(
|
||||
(queryKey: QueryKey) =>
|
||||
matchesWidgetItemQuery(
|
||||
queryKey,
|
||||
widgetQueryKeys,
|
||||
{
|
||||
itemId: item.id,
|
||||
boardId: board.id,
|
||||
integrationIds: item.integrationIds,
|
||||
options,
|
||||
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
|
||||
},
|
||||
definition.queryMatcher,
|
||||
),
|
||||
[board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef],
|
||||
);
|
||||
const isWidgetFetching =
|
||||
useIsFetching({
|
||||
type: "active",
|
||||
predicate: (query) => matchesWidgetQuery(query.queryKey),
|
||||
}) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) });
|
||||
}, [matchesWidgetQuery, queryClient]);
|
||||
|
||||
const canInteractWithSelectedIntegrations = useMemo(() => {
|
||||
const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
|
||||
return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
|
||||
}, [integrationsWithInteractAccess, item.integrationIds]);
|
||||
|
||||
const setItemOptions = useCallback(
|
||||
(partial: Record<string, unknown>) => {
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem,
|
||||
),
|
||||
}));
|
||||
},
|
||||
[item.id, persistBoard],
|
||||
);
|
||||
|
||||
type OptionDef = { type: string; skipContextMenu?: boolean };
|
||||
const toggleOptions = useMemo(() => {
|
||||
if (!canConfigureWidget) return [];
|
||||
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
|
||||
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
|
||||
return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
|
||||
}, [canConfigureWidget, definition, settings]);
|
||||
|
||||
const widgetContextActions = useMemo(() => {
|
||||
const def = definition as unknown as Record<string, unknown>;
|
||||
if (typeof def.contextActions !== "function") return [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actions = (def.contextActions as any)({
|
||||
const widgetContextActions =
|
||||
definition.contextActions?.({
|
||||
options,
|
||||
setOptions: (partial: Record<string, unknown>) => {
|
||||
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
|
||||
},
|
||||
setOptions: setItemOptions,
|
||||
integrationIds: item.integrationIds,
|
||||
context: { isEditMode, boardId: board.id, itemId: item.id },
|
||||
widgetStateRef,
|
||||
});
|
||||
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
|
||||
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updatedItems: typeof board.items) => {
|
||||
saveBoard({ ...board, items: updatedItems });
|
||||
},
|
||||
[board, saveBoard],
|
||||
);
|
||||
context: {
|
||||
isEditMode,
|
||||
boardId: board.id,
|
||||
itemId: item.id,
|
||||
canInteractWithSelectedIntegrations,
|
||||
},
|
||||
widgetRuntimeRef,
|
||||
}) ?? [];
|
||||
|
||||
const openEditModal = useCallback(() => {
|
||||
openModal(
|
||||
@@ -159,27 +164,24 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
|
||||
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
|
||||
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
|
||||
if (!isEditMode) {
|
||||
persistBoard(
|
||||
board.items.map((boardItem) =>
|
||||
boardItem.id !== item.id
|
||||
? boardItem
|
||||
: {
|
||||
...boardItem,
|
||||
options: editResult.options,
|
||||
advancedOptions: editResult.advancedOptions,
|
||||
integrationIds: editResult.integrationIds,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.map((boardItem) =>
|
||||
boardItem.id !== item.id
|
||||
? boardItem
|
||||
: {
|
||||
...boardItem,
|
||||
options: editResult.options,
|
||||
advancedOptions: editResult.advancedOptions,
|
||||
integrationIds: editResult.integrationIds,
|
||||
},
|
||||
),
|
||||
}));
|
||||
},
|
||||
integrationData: integrationData.filter(
|
||||
(integration) =>
|
||||
"supportedIntegrations" in definition &&
|
||||
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
integrationData: integrationData.filter((integration) =>
|
||||
definition.supportedIntegrations?.some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in definition,
|
||||
integrationSupport: definition.supportedIntegrations !== undefined,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
@@ -187,72 +189,87 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
|
||||
);
|
||||
}, [
|
||||
openModal,
|
||||
definition,
|
||||
integrationData,
|
||||
item,
|
||||
updateItemOptions,
|
||||
openModal,
|
||||
persistBoard,
|
||||
settings,
|
||||
updateItemAdvancedOptions,
|
||||
updateItemIntegrations,
|
||||
integrationData,
|
||||
definition,
|
||||
settings,
|
||||
isEditMode,
|
||||
persistBoard,
|
||||
board,
|
||||
updateItemOptions,
|
||||
]);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(key: string) => (checked: boolean) => {
|
||||
const newOptions = { ...options, [key]: checked };
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
persistBoard(
|
||||
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
|
||||
);
|
||||
},
|
||||
[options, item.id, updateItemOptions, persistBoard, board],
|
||||
(key: string) => (checked: boolean) => setItemOptions({ [key]: checked }),
|
||||
[setItemOptions],
|
||||
);
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
|
||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
|
||||
|
||||
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
|
||||
|
||||
return (
|
||||
<>
|
||||
{toggleOptions.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||
{toggleOptions.map(([key]) => (
|
||||
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<Group wrap="nowrap">
|
||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
|
||||
</Group>
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={300}
|
||||
closeOnItemClick={false}
|
||||
position="right-start"
|
||||
offset={4}
|
||||
opened={menuOpened}
|
||||
onChange={setMenuOpened}
|
||||
>
|
||||
<Menu.ContextMenu>{children}</Menu.ContextMenu>
|
||||
<Menu.Dropdown>
|
||||
{canOpenAdvancedFocus && (
|
||||
<>
|
||||
<Menu.Item
|
||||
closeMenuOnClick
|
||||
leftSection={<IconMaximize size={16} />}
|
||||
onClick={() => {
|
||||
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
|
||||
}}
|
||||
>
|
||||
{t("item.advancedFocus.open")}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleWidgetActions.length > 0 && (
|
||||
<>
|
||||
{toggleOptions.length > 0 && <Menu.Divider />}
|
||||
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
||||
{visibleWidgetActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.key}
|
||||
closeMenuOnClick
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
color={action.color}
|
||||
>
|
||||
{String(translateIfNecessary(t, action.label))}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{toggleOptions.length > 0 && (
|
||||
<>
|
||||
<Menu.Label>{tMenu("options")}</Menu.Label>
|
||||
{toggleOptions.map(([key]) => (
|
||||
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
|
||||
</Menu.CheckboxItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{visibleWidgetActions.length > 0 && (
|
||||
<>
|
||||
{toggleOptions.length > 0 && <Menu.Divider />}
|
||||
<Menu.Label>{tMenu("actions")}</Menu.Label>
|
||||
{visibleWidgetActions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.key}
|
||||
closeMenuOnClick
|
||||
leftSection={Icon ? <Icon size={16} /> : undefined}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
color={action.color}
|
||||
>
|
||||
{translateIfNecessary(t, action.label)}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
@@ -263,7 +280,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
{tMenu("refresh")}
|
||||
<WidgetQueryStatus
|
||||
queryClient={queryClient}
|
||||
queryKeys={widgetQueryKeys}
|
||||
matchesQuery={matchesWidgetQuery}
|
||||
isFetching={isWidgetFetching}
|
||||
t={t}
|
||||
/>
|
||||
@@ -279,23 +296,27 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
|
||||
>
|
||||
{tMenu("settings")}
|
||||
</Menu.Item>
|
||||
</>
|
||||
</>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface WidgetQueryStatusProps {
|
||||
queryClient: QueryClient;
|
||||
queryKeys: QueryKey[];
|
||||
matchesQuery: (queryKey: QueryKey) => boolean;
|
||||
isFetching: boolean;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))];
|
||||
const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => {
|
||||
const queries = queryClient.getQueryCache().findAll({
|
||||
type: "active",
|
||||
predicate: (query) => matchesQuery(query.queryKey),
|
||||
});
|
||||
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
|
||||
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
|
||||
const ageLabel = useTimeAgo(new Date(latest));
|
||||
const ageLabel = useTimeAgo(new Date(latest || Date.now()));
|
||||
const ageSuffix = latest > 0 ? ` · ${ageLabel}` : "";
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
@@ -316,22 +337,15 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
|
||||
);
|
||||
}
|
||||
|
||||
const hasError = queries.some((q) => q.state.status === "error");
|
||||
|
||||
const hasError = queries.some((query) => query.state.status === "error");
|
||||
if (hasError) {
|
||||
const errorQuery = queries.find((q) => q.state.status === "error");
|
||||
const errorMessage =
|
||||
errorQuery?.state.error instanceof Error
|
||||
? errorQuery.state.error.message
|
||||
: String(errorQuery?.state.error ?? "Unknown error");
|
||||
|
||||
return (
|
||||
<Tooltip label={errorMessage} multiline position="left" w={250}>
|
||||
<Tooltip label={t("item.menu.status.error")} position="left">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" c="red.6">
|
||||
{t("item.menu.status.error")}
|
||||
{latest > 0 && ` · ${ageLabel}`}
|
||||
{ageSuffix}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
@@ -343,7 +357,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
|
||||
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.menu.status.success")}
|
||||
{latest > 0 && ` · ${ageLabel}`}
|
||||
{ageSuffix}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -105,6 +105,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.panel {
|
||||
border-color: CanvasText;
|
||||
background: Canvas;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
.metadataValue {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box, Menu } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ItemSelectModal } from "../../items/item-select-modal";
|
||||
import { useBoardPermissions } from "../../permissions/client";
|
||||
import type { BoardPlacementHint } from "../../items/actions/create-item";
|
||||
import type { UseGridstackRefs } from "./use-gridstack";
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: BoardPlacementHint;
|
||||
}
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { openModal } = useModalAction(ItemSelectModal);
|
||||
const [isEditMode] = useEditMode();
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer.current === null) return;
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
|
||||
const wrapper = refs.wrapperElement.current;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!(target instanceof Element) || !wrapper || !gridstack) return null;
|
||||
if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
|
||||
|
||||
const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
|
||||
const rowLimit = section.kind === "dynamic" ? section.height : undefined;
|
||||
if (
|
||||
cell.x < 0 ||
|
||||
cell.y < 0 ||
|
||||
cell.x >= gridstack.getColumn() ||
|
||||
(rowLimit !== undefined && cell.y >= rowLimit) ||
|
||||
!gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || event.pointerType !== "touch") return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
clearLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
navigator.vibrate?.(20);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!enabled ||
|
||||
event.currentTarget !== event.target ||
|
||||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const wrapper = event.currentTarget;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!gridstack) return;
|
||||
|
||||
const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
|
||||
for (let yOffset = 0; yOffset < rowCount; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
|
||||
if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
event.preventDefault();
|
||||
returnFocusRef.current = wrapper;
|
||||
setMenu({
|
||||
x: rect.left + 24,
|
||||
y: rect.top + 24,
|
||||
placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
{...boxProps}
|
||||
ref={refs.wrapper}
|
||||
tabIndex={enabled ? 0 : undefined}
|
||||
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={clearLongPress}
|
||||
onPointerUp={clearLongPress}
|
||||
onPointerCancel={clearLongPress}
|
||||
onPointerLeave={clearLongPress}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Menu
|
||||
opened={menu !== null}
|
||||
onChange={(opened) => {
|
||||
if (!opened) {
|
||||
setMenu(null);
|
||||
const returnFocus = returnFocusRef.current;
|
||||
returnFocusRef.current = null;
|
||||
requestAnimationFrame(() => returnFocus?.focus());
|
||||
}
|
||||
}}
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={() => {
|
||||
returnFocusRef.current = null;
|
||||
if (menu) {
|
||||
openModal({ placement: menu.placement, board, persistImmediately: !isEditMode });
|
||||
}
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{t("item.create.addAtPosition")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const appShellHeaderHeight = 60;
|
||||
@@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
import { useOptionalBackgroundProps } from "./background";
|
||||
import { appShellHeaderHeight } from "./constants";
|
||||
import { navigationCollapsedAtom } from "./header/burger";
|
||||
|
||||
interface ClientShellProps {
|
||||
@@ -23,7 +24,7 @@ export const ClientShell = ({
|
||||
return (
|
||||
<AppShell
|
||||
{...backgroundProps}
|
||||
header={hasHeader ? { height: 60 } : undefined}
|
||||
header={hasHeader ? { height: appShellHeaderHeight } : undefined}
|
||||
navbar={
|
||||
hasNavigation
|
||||
? {
|
||||
|
||||
Reference in new issue
Block a user