fix: refine dashboard interactions and widget options (#6597)

* fix: refine dashboard item interactions

* fix board interactions and widget options

* feat: improve bookmark selection

* fix: address dashboard review feedback
This commit is contained in:
Thomas authored and GitHub committed 2026-08-15 10:44:37 +02:00
1 parent b491d45fdc
commit 4505ce96c9
37 files changed
+824 -503

No files matched your search

+3 -2
View File
@@ -13,8 +13,9 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
The bookmarks widget allows you to display multiple links in a single widget.
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
For links that do not need an App, add their URLs under **Custom links** instead.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
+7 -1
View File
@@ -47,9 +47,15 @@ export const bookmarksWidget: WidgetDefinition = {
{
name: "Bookmarks",
description: "List of bookmarks to display in the widget.",
values: "Select of all your apps and order them as you like.",
values: "Select one or more Apps directly, then order them as you like.",
defaultValue: "No bookmarks selected",
},
{
name: "Custom links",
description: "URLs to display without first creating an App.",
values: "List of URLs.",
defaultValue: "No custom links",
},
],
},
};
+1 -1
View File
@@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
preloadEntriesOnStart: false,
preloadEntriesOnStart: true,
turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
},
@@ -49,7 +49,7 @@ export const ClientBoard = () => {
.map(getLogicalTrackSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
@@ -74,7 +74,7 @@ export const ClientBoard = () => {
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
@@ -1,15 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
@@ -36,7 +37,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
const { openConfirmModal } = useConfirmModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
@@ -95,16 +95,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
);
};
const openRemoveModal = () => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => {
removeItem({ itemId: item.id });
},
});
};
return (
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
<Menu.Target>
@@ -123,7 +113,6 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
@@ -149,10 +138,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
</Menu.Item>
)}
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tItem("action.remove")}
</Menu.Item>
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
onConfirm={() => removeItem({ itemId: item.id })}
/>
</Menu.Dropdown>
</Menu>
);
@@ -75,14 +75,14 @@ export const ScaledBoardCanvas = ({
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
>
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
<Box className={classes.sizer} style={{ width: visualWidth, height: resolvedLogicalHeight * scale }}>
<Box
ref={canvasRef}
className={classes.canvas}
w={logicalWidth}
style={{
"--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
width: logicalWidth,
zoom: scale,
}}
>
@@ -0,0 +1,39 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Menu } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
interface BoardRemoveConfirmationMenuItemProps {
label: ReactNode;
confirmationLabel: ReactNode;
onConfirm: () => void;
}
export const BoardRemoveConfirmationMenuItem = ({
label,
confirmationLabel,
onConfirm,
}: BoardRemoveConfirmationMenuItemProps) => {
const [isConfirmationRequired, setIsConfirmationRequired] = useState(false);
return (
<Menu.Item
c="red.6"
leftSection={<IconTrash size={16} />}
closeMenuOnClick={isConfirmationRequired}
onBlur={() => setIsConfirmationRequired(false)}
onClick={() => {
if (isConfirmationRequired) {
onConfirm();
return;
}
setIsConfirmationRequired(true);
}}
>
{isConfirmationRequired ? confirmationLabel : label}
</Menu.Item>
);
};
@@ -1,6 +1,6 @@
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
import { IconChevronDown, IconChevronUp, IconExternalLink } from "@tabler/icons-react";
import combineClasses from "clsx";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -38,7 +38,7 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = isEditMode ? 40 : 8;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
@@ -101,22 +101,6 @@ export const BoardContainerSection = ({ section }: Props) => {
)}
</Button>
)}
{isEditMode && (
<ActionIcon
component="span"
pos="absolute"
top={4}
left={4}
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
variant="default"
size={24}
radius="sm"
data-grid-container-drag-handle
aria-hidden="true"
>
<IconGripVertical size={16} />
</ActionIcon>
)}
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
<Badge
pos="absolute"
@@ -162,6 +146,7 @@ export const BoardContainerSection = ({ section }: Props) => {
id={contentId}
className={classes.containerBody}
h="100%"
data-board-container-body
data-collapsed={isVisuallyCollapsed ? "true" : "false"}
aria-hidden={isVisuallyCollapsed}
inert={isVisuallyCollapsed}
@@ -1,28 +1,27 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconArrowsMove, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { IconArrowsMove, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../../remove-confirmation-menu-item";
import { useOpenItemMoveModal } from "../../items/item-move-modal";
import { useSectionContext } from "../section-context";
import { useContainerActions } from "./container-actions";
import { ContainerEditModal } from "./container-edit-modal";
export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem }) => {
const t = useI18n();
const tContainer = useScopedI18n("section.container");
const tItem = useScopedI18n("item");
const { openModal } = useModalAction(ContainerEditModal);
const openMoveModal = useOpenItemMoveModal();
const { updateContainer, removeContainer } = useContainerActions();
const { openConfirmModal } = useConfirmModal();
const [isEditMode] = useEditMode();
const { section: parentSection } = useSectionContext();
const label = section.options.title || tContainer("action.create");
const menuRightOffset = parentSection.kind === "container" ? 44 : 4;
const menuLeftOffset = parentSection.kind === "container" ? 36 : 4;
if (!isEditMode) return null;
@@ -33,14 +32,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
});
};
const openRemoveModal = () => {
openConfirmModal({
title: tContainer("remove.title"),
children: tContainer("remove.message"),
onConfirm: () => removeContainer({ id: section.id }),
});
};
return (
<Menu withinPortal position="right-start" arrowPosition="center">
<Menu.Target>
@@ -49,8 +40,8 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
size={24}
radius="sm"
pos="absolute"
top={-24}
right={menuRightOffset}
top={4}
left={menuLeftOffset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
@@ -58,7 +49,6 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
<Menu.Item leftSection={<IconPencil size={16} />} onClick={openEditModal}>
{tItem("action.edit")}
</Menu.Item>
@@ -69,10 +59,11 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Divider />
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
<Menu.Item c="red.6" leftSection={<IconTrash size={16} />} onClick={openRemoveModal}>
{tContainer("action.remove")}
</Menu.Item>
<BoardRemoveConfirmationMenuItem
label={tContainer("action.remove")}
confirmationLabel={tContainer("remove.message")}
onConfirm={() => removeContainer({ id: section.id })}
/>
</Menu.Dropdown>
</Menu>
);
@@ -22,7 +22,7 @@ interface TestPlacement {
y: number;
w: number;
h: number;
kind: "container" | "item";
type: "section" | "item";
marker?: string;
}
@@ -32,8 +32,8 @@ const placement = (
y: number,
w = 1,
h = 1,
kind: TestPlacement["kind"] = "item",
): TestPlacement => ({ id, x, y, w, h, kind, marker: `metadata:${id}` });
type: TestPlacement["type"] = "item",
): TestPlacement => ({ id, x, y, w, h, type, marker: `metadata:${id}` });
const grid = (
id: string,
@@ -302,6 +302,55 @@ describe("same-grid movement", () => {
expect(getPlacement(moved.preview, "far")).toMatchObject({ x: 3, y: 8 });
});
test("keeps a container fixed when an item is moved onto it", () => {
const original = state(grid("root", 4, [placement("active", 0, 0), placement("container", 1, 0, 2, 2, "section")]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
});
test("lets an active container push an item", () => {
const original = state(
grid("root", 4, [placement("container", 0, 0, 2, 2, "section"), placement("item", 2, 0, 2)]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "container" }), {
targetGridId: "root",
x: 2,
y: 0,
}),
);
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 2, y: 0, w: 2, h: 2 });
expect(getPlacement(moved.preview, "item")).toMatchObject({ x: 0, y: 0 });
});
test("does not let an active container displace another container", () => {
const original = state(
grid("root", 4, [
placement("active-container", 0, 0, 2, 2, "section"),
placement("fixed-container", 2, 0, 2, 2, "section"),
]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active-container" }), {
targetGridId: "root",
x: 2,
y: 0,
}),
);
expect(getPlacement(moved.preview, "fixed-container")).toMatchObject({ x: 2, y: 0 });
expect(getPlacement(moved.preview, "active-container")).toMatchObject({ x: 2, y: 2 });
});
test.each([
{ requested: { x: -20, y: -10 }, expected: { x: 0, y: 0 } },
{ requested: { x: 99, y: 99 }, expected: { x: 2, y: 3 } },
@@ -408,9 +457,26 @@ describe("cross-grid movement", () => {
expect(getPlacement(second.preview, "second-neighbor")).toMatchObject({ x: 0, y: 1 });
});
test("keeps a destination container fixed when an item enters its grid", () => {
const original = state(
grid("source", 4, [placement("active", 0, 0)]),
grid("target", 4, [placement("container", 1, 0, 2, 2, "section")]),
);
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "target",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 0, w: 2, h: 2 });
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 2 });
});
test("moves a container's owned grid with it and supports commit/cancel", () => {
const original = state(
grid("source", 3, [placement("container", 0, 0, 2, 2, "container")]),
grid("source", 3, [placement("container", 0, 0, 2, 2, "section")]),
grid("target", 3, []),
grid("container-content", 2, [placement("child", 0, 0)], {
parentGridId: "source",
@@ -421,7 +487,7 @@ describe("cross-grid movement", () => {
const moved = accept(previewGridMove(transaction, { targetGridId: "target", x: 1, y: 2 }));
expect(getGrid(moved.preview, "container-content").parentGridId).toBe("target");
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, kind: "container" });
expect(getPlacement(moved.preview, "container")).toMatchObject({ x: 1, y: 2, type: "section" });
expect(cancelGridTransaction(moved)).toEqual(original);
expect(commitGridTransaction(moved)).toEqual(moved.preview);
expect(commitGridTransaction(moved)).not.toBe(moved.preview);
@@ -429,8 +495,8 @@ describe("cross-grid movement", () => {
test.each(["child", "grandchild"])("rejects a container move into its %s grid", (targetGridId) => {
const original = state(
grid("root", 4, [placement("container", 0, 0, 2, 2, "container")]),
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "container")], {
grid("root", 4, [placement("container", 0, 0, 2, 2, "section")]),
grid("child", 3, [placement("nested-container", 0, 0, 2, 2, "section")], {
parentGridId: "root",
ownerPlacementId: "container",
}),
@@ -150,7 +150,8 @@ const previewSameGridMove = <TPlacement extends GridPlacement>(
const collisions = grid.placements.filter(
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
);
const displaced = collisions.length === 1 ? collisions[0] : undefined;
const displacedCandidate = collisions.length === 1 ? collisions[0] : undefined;
const displaced = displacedCandidate && !isContainerPlacement(displacedCandidate) ? displacedCandidate : undefined;
const swapped = displaced
? grid.placements.map((placement) => {
@@ -209,8 +210,10 @@ const previewCrossGridMove = <TPlacement extends GridPlacement>(
};
/**
* Pins the active placement, keeps every non-colliding item at its persisted
* coordinates, and pushes only the deterministic collision chain downward.
* Keeps stationary containers fixed, resolves the active placement around
* those obstacles, and then pushes only the deterministic item collision chain
* downward. A container can still be the active placement and push items, but
* no active placement can displace another container.
*/
export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
@@ -223,8 +226,24 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
const pinned = normalized.find((placement) => placement.id === pinnedPlacement.id);
if (!pinned) throw new Error(`Pinned grid item "${pinnedPlacement.id}" does not exist`);
const placed: TPlacement[] = [pinned];
const candidates = normalized.filter((placement) => placement.id !== pinned.id).toSorted(comparePlacements);
const fixedContainers = normalized.filter(
(placement) => placement.id !== pinned.id && isContainerPlacement(placement),
);
const placed: TPlacement[] = [...fixedContainers];
let resolvedPinned = pinned;
for (;;) {
const collisions = placed.filter((placement) => doGridPlacementsOverlap(resolvedPinned, placement));
if (collisions.length === 0) break;
resolvedPinned = {
...resolvedPinned,
y: Math.max(...collisions.map((placement) => placement.y + placement.h)),
};
}
placed.push(resolvedPinned);
const candidates = normalized
.filter((placement) => placement.id !== pinned.id && !isContainerPlacement(placement))
.toSorted(comparePlacements);
for (const candidate of candidates) {
let resolved = candidate;
for (;;) {
@@ -245,6 +264,8 @@ export const resolvePinnedGridCollisions = <TPlacement extends GridPlacement>(
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
first.y - second.y || first.x - second.x || first.id.localeCompare(second.id);
const isContainerPlacement = (placement: GridPlacement) => "type" in placement && placement.type === "section";
const clampMovePlacement = <TPlacement extends GridPlacement>(
placement: TPlacement,
input: GridMoveInput,
@@ -14,7 +14,7 @@ export {
getSnappedGridDelta,
} from "./coordinates";
export type { DragProjectionOrigin } from "./coordinates";
export { getPreferredNestedExitTargetId } from "./targeting";
export { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
export type { GridTargetGeometry } from "./targeting";
export type {
BeginGridTransactionInput,
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { GridTargetGeometry } from "./targeting";
import { getPreferredNestedExitTargetId } from "./targeting";
import { getGridTargetFallbackIds, getPreferredNestedExitTargetId } from "./targeting";
const grid = (
id: string,
@@ -66,3 +66,30 @@ describe("nested grid exit targeting", () => {
).toBe("container");
});
});
describe("nested grid fallback targeting", () => {
const grids = [
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
];
test("falls back from a blocked container to its parent canvas", () => {
expect(
getGridTargetFallbackIds({
grids,
targetGridId: "container",
isEligible: alwaysEligible,
}),
).toEqual(["container", "canvas"]);
});
test("does not cross an ineligible hierarchy boundary", () => {
expect(
getGridTargetFallbackIds({
grids,
targetGridId: "container",
isEligible: (gridId) => gridId !== "canvas",
}),
).toEqual(["container"]);
});
});
@@ -11,6 +11,12 @@ export interface GridTargetGeometry {
rectangle: RectangleLike;
}
interface GridTargetFallbackInput {
grids: readonly Pick<GridTargetGeometry, "id" | "parentGridId">[];
targetGridId: string;
isEligible: (gridId: string) => boolean;
}
interface PreferredNestedExitTargetInput {
grids: readonly GridTargetGeometry[];
pointerTargetId: string;
@@ -48,6 +54,26 @@ export const getPreferredNestedExitTargetId = ({
return current.id;
};
/**
* Tries the visible nested grid first, then its ancestors. A full or too-small
* container must not trap an item that can still move in the parent grid.
*/
export const getGridTargetFallbackIds = ({ grids, targetGridId, isEligible }: GridTargetFallbackInput) => {
const byId = new Map(grids.map((grid) => [grid.id, grid]));
const ids: string[] = [];
const visited = new Set<string>();
let current = byId.get(targetGridId);
while (current && !visited.has(current.id)) {
visited.add(current.id);
if (!isEligible(current.id)) break;
ids.push(current.id);
current = current.parentGridId ? byId.get(current.parentGridId) : undefined;
}
return ids;
};
const isSameGridOrAncestor = (
candidateId: string,
source: GridTargetGeometry,
@@ -93,6 +93,11 @@ body[data-board-grid-interacting] .board-grid-placeholder {
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;
}
@@ -124,14 +129,52 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
}
.board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
}
.board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled);
}
[data-section-kind="empty"][data-rail-placement="main"] > .board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
box-shadow: none;
}
[data-section-kind="empty"][data-rail-placement="main"]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"] {
outline-color: var(--mantine-color-red-filled);
}
[data-board-container-collapsed]
> [data-board-container-body]
> [data-section-id]
> .board-grid-editor[data-dnd-drop-target="true"] {
box-shadow: none;
}
[data-board-container-collapsed]:has(
> [data-board-container-body] > [data-section-id] > .board-grid-editor[data-dnd-drop-target="true"]
)::after {
position: absolute;
inset: 0;
z-index: 19;
box-sizing: border-box;
content: "";
border: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
border-radius: inherit;
pointer-events: none;
}
[data-board-container-collapsed]:has(
> [data-board-container-body]
> [data-section-id]
> .board-grid-editor[data-dnd-drop-target="true"][data-dnd-drop-valid="false"]
)::after {
border-color: var(--mantine-color-red-filled);
}
.board-grid-resize-handle {
position: absolute;
z-index: 20;
@@ -143,10 +186,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
touch-action: none;
}
.board-grid-resize-handle[data-grid-resize-disabled="true"] {
pointer-events: none;
}
body[data-board-grid-interacting] .board-grid-resize-handle {
pointer-events: none;
}
@@ -155,16 +194,6 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
opacity: 0;
}
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"] {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 4px;
}
.board-grid-entry[data-grid-item-type="section"][data-grid-section-chrome-active="true"]
> .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
opacity: 1;
}
.board-grid-resize-handle::after {
position: absolute;
inset: 50% auto auto 50%;
@@ -178,8 +207,8 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
transition: opacity 100ms ease;
}
.board-grid-entry:hover > .board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after,
.board-grid-resize-handle:not([data-grid-resize-disabled="true"]):focus-visible::after {
.board-grid-entry:hover > .board-grid-resize-handle::after,
.board-grid-resize-handle:focus-visible::after {
opacity: 1;
}
@@ -270,7 +299,7 @@ body[data-board-grid-interacting] .board-grid-resize-handle::after {
}
@media (hover: none), (pointer: coarse) {
.board-grid-resize-handle:not([data-grid-resize-disabled="true"])::after {
.board-grid-resize-handle::after {
opacity: 1;
}
}
@@ -37,7 +37,6 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
beginGridTransaction,
@@ -45,6 +44,7 @@ import {
commitGridTransaction,
getContinuousGridDelta,
getContinuousResizePlacement,
getGridTargetFallbackIds,
getPreferredNestedExitTargetId,
getPointerProjectedShape,
getSnappedGridCoordinates,
@@ -56,7 +56,6 @@ 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 { observeGridDragHandle } from "./grid-drag-handle";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import type { GridInteraction } from "./grid-preview-layer";
@@ -195,7 +194,10 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
document.body.removeAttribute("data-board-grid-interacting");
}, [interactionStore]);
useEffect(() => clearInteraction, [clearInteraction, currentLayoutId]);
useLayoutEffect(() => {
clearInteraction();
return clearInteraction;
}, [board.items, board.sections, clearInteraction, currentLayoutId]);
const registerGrid = useCallback((grid: RegisteredGrid) => {
gridsRef.current.set(grid.id, grid);
@@ -348,66 +350,56 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return;
}
const coordinates = getSnappedGridCoordinates({
dragShape: shape,
target: targetGrid.element.getBoundingClientRect(),
columnCount: targetGrid.columnCount,
const fallbackIds = getGridTargetFallbackIds({
grids: Array.from(gridsRef.current.values()),
targetGridId: targetGrid.id,
isEligible: (targetGridId) => isDropTargetEligible(source, targetGridId),
});
if (!coordinates) {
showInvalidPreview(targetGrid.id, `invalid:${targetGrid.id}:coordinates`);
return;
}
const { x, y } = coordinates;
const targetAcceptsSource = canAcceptDrop(source, targetGrid.id);
const previewKey = `${targetGrid.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
if (active.lastPreviewKey === previewKey) return;
if (!targetAcceptsSource) {
showInvalidPreview(targetGrid.id, previewKey);
return;
}
let invalidPreviewKey = `invalid:${targetGrid.id}:no-candidate`;
const result = previewGridMove(active.transaction, { targetGridId: targetGrid.id, x, y });
if (
result.accepted &&
doesPreviewDisplaceNestedGridOwner(
active.transaction.snapshot,
result.transaction.preview,
targetGrid.id,
active.transaction.activeId,
)
) {
showInvalidPreview(targetGrid.id, `${previewKey}:nested-owner`);
return;
}
active.lastPreviewKey = previewKey;
active.targetGridId = targetGrid.id;
active.valid = result.accepted;
if (!result.accepted) {
for (const candidateId of fallbackIds) {
const candidate = gridsRef.current.get(candidateId);
if (!candidate) continue;
const coordinates = getSnappedGridCoordinates({
dragShape: shape,
target: candidate.element.getBoundingClientRect(),
columnCount: candidate.columnCount,
});
if (!coordinates) {
invalidPreviewKey = `invalid:${candidate.id}:coordinates`;
continue;
}
const { x, y } = coordinates;
const targetAcceptsSource = canAcceptDrop(source, candidate.id);
const previewKey = `${candidate.id}:${x}:${y}:${targetAcceptsSource ? "candidate" : "invalid"}`;
invalidPreviewKey = previewKey;
if (!targetAcceptsSource) continue;
const result = previewGridMove(active.transaction, { targetGridId: candidate.id, x, y });
if (!result.accepted) continue;
if (active.lastPreviewKey === previewKey) return;
active.lastPreviewKey = previewKey;
active.targetGridId = candidate.id;
active.valid = true;
active.transaction = result.transaction;
const targetPlacement = result.transaction.preview.grids
.find((grid) => grid.id === candidate.id)
?.placements.find((placement) => placement.id === sourceData.placement.id);
publishPreview({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: targetGrid.id,
targetPlacement: null,
state: cancelGridTransaction(active.transaction),
targetGridId: candidate.id,
targetPlacement: targetPlacement ?? null,
state: result.transaction.preview,
mode: "drag",
valid: false,
valid: true,
});
return;
}
active.transaction = result.transaction;
const targetPlacement = result.transaction.preview.grids
.find((grid) => grid.id === targetGrid.id)
?.placements.find((placement) => placement.id === sourceData.placement.id);
publishPreview({
activeId: active.transaction.activeId,
sourceGridId: active.transaction.sourceGridId,
targetGridId: targetGrid.id,
targetPlacement: targetPlacement ?? null,
state: result.transaction.preview,
mode: "drag",
valid: true,
});
showInvalidPreview(targetGrid.id, invalidPreviewKey);
},
[canAcceptDrop, interactionStore, isDropTargetEligible],
);
@@ -762,9 +754,9 @@ export default function GridEditor({
data: { kind: GRID_TARGET_TYPE, sectionId },
collisionDetector: pointerIntersection,
collisionPriority: CollisionPriority.Highest + depth,
// `accept` only removes the active container's own subtree. Size limits
// are validated after collision so an invalid nested target cannot fall
// through to an accepting parent grid underneath the pointer.
// `accept` removes only the active container's own subtree. Size limits
// are evaluated during preview targeting, which can fall back from an
// invalid nested grid to an eligible parent.
accept: (source) => isDropTargetEligible(source, sectionId),
});
@@ -852,9 +844,8 @@ const DndGridEntryComponent = ({
maxRowCount,
registerElement,
}: DndGridEntryProps) => {
const { acquireContainer } = useBoardGridPortalHost();
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
@@ -908,8 +899,9 @@ const DndGridEntryComponent = ({
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
handleRef(element);
},
[draggableRef, placement.id, registerElement],
[draggableRef, handleRef, placement.id, registerElement],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
@@ -918,35 +910,12 @@ const DndGridEntryComponent = ({
const mount = mountRef.current;
if (!mount) return;
const container = acquireContainer(placement.id);
mount.appendChild(container);
mount.replaceChildren(container);
const handleSelector =
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
return observeGridDragHandle(container, handleSelector, handleRef);
}, [acquireContainer, handleRef, placement.id, placement.type]);
useLayoutEffect(() => {
const shell = shellRef.current;
if (placement.type !== "section" || !shell) return;
const handlePointerDownCapture = (event: PointerEvent) => {
if (!(event.target instanceof Element)) return;
const grip = event.target.closest("[data-grid-container-drag-handle]");
if (grip) {
const owner = grip.closest('.board-grid-entry[data-grid-item-type="section"]');
setIsSectionChromeActive(owner === shell);
return;
}
const resizeHandle = event.target.closest(".board-grid-resize-handle");
if (!resizeHandle || resizeHandle.parentElement !== shell) {
setIsSectionChromeActive(false);
}
return () => {
if (container.parentElement === mount) container.remove();
};
shell.addEventListener("pointerdown", handlePointerDownCapture, { capture: true });
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
}, [placement.type]);
}, [acquireContainer, placement.id, structureRevision]);
return (
<div
@@ -959,7 +928,6 @@ const DndGridEntryComponent = ({
data-grid-y={placement.y}
data-grid-w={placement.w}
data-grid-h={placement.h}
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
>
@@ -968,7 +936,6 @@ const DndGridEntryComponent = ({
sectionId={sectionId}
placement={placement}
label={label}
disabled={placement.type === "section" && !isSectionChromeActive}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
@@ -987,7 +954,6 @@ interface GridResizeHandlesProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
disabled: boolean;
columnCount: number;
maxRowCount: number | null;
beginResize: BoardGridEditorActionsContextValue["beginResize"];
@@ -1003,7 +969,6 @@ const GridResizeHandles = ({
sectionId,
placement,
label,
disabled,
columnCount,
maxRowCount,
beginResize,
@@ -1083,15 +1048,7 @@ const GridResizeHandles = ({
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
if (
disabled ||
!event.isPrimary ||
event.button !== 0 ||
pointerStartRef.current ||
!visualScale ||
!start(direction)
)
return;
if (!event.isPrimary || event.button !== 0 || pointerStartRef.current || !visualScale || !start(direction)) return;
event.preventDefault();
event.stopPropagation();
try {
@@ -1150,7 +1107,6 @@ const GridResizeHandles = ({
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
data-grid-resize-disabled={disabled ? "true" : undefined}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
@@ -1281,40 +1237,17 @@ const getPreferredRegisteredGridTarget = (
return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget;
};
const doesPreviewDisplaceNestedGridOwner = (
snapshot: TransactionalGridState<SectionGridPlacement>,
preview: TransactionalGridState<SectionGridPlacement>,
targetGridId: string,
activeId: string,
) => {
const ownerIds = new Set(
snapshot.grids
.filter((grid) => grid.parentGridId === targetGridId && grid.ownerPlacementId !== activeId)
.flatMap((grid) => (grid.ownerPlacementId ? [grid.ownerPlacementId] : [])),
);
if (ownerIds.size === 0) return false;
const original = snapshot.grids.find((grid) => grid.id === targetGridId);
const candidate = preview.grids.find((grid) => grid.id === targetGridId);
if (!original || !candidate) return false;
const originalById = new Map(original.placements.map((placement) => [placement.id, placement]));
return candidate.placements.some((placement) => {
const previous = originalById.get(placement.id);
return Boolean(previous && ownerIds.has(placement.id) && !areGridPlacementCoordinatesEqual(previous, placement));
});
};
const areGridPlacementCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
const target = event.target;
if (!(target instanceof Element) || target === source.handle) return false;
const activationElement = source.handle ?? source.element;
if (!(target instanceof Element) || target === activationElement) return false;
const targetShell = target.closest(".board-grid-entry");
if (targetShell && targetShell !== activationElement) return true;
const interactive = target.closest(
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.board-grid-resize-handle',
);
return Boolean(interactive && source.handle?.contains(interactive));
return Boolean(interactive && activationElement?.contains(interactive));
};
const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGridPlacement) => {
@@ -12,7 +12,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { getRootSectionLane } from "@homarr/definitions";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
import { getBoardLaneColumnCount } from "~/components/board/layout";
import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
@@ -23,6 +23,7 @@ interface GridPortalHostContextValue {
integrations: RouterOutputs["integration"]["all"] | undefined;
containers: ReadonlyMap<string, HTMLElement>;
acquireContainer: (id: string) => HTMLElement;
structureRevision: string;
}
const GridPortalHostContext = createContext<GridPortalHostContextValue | null>(null);
@@ -50,6 +51,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
() => 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);
@@ -81,8 +83,9 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
integrations,
containers,
acquireContainer,
structureRevision,
}),
[acquireContainer, announce, containers, integrations],
[acquireContainer, announce, containers, integrations, structureRevision],
);
return (
@@ -143,26 +146,23 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
const entry = persistedEntry;
const rawSection = board.sections.find((section) => section.id === ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const persistedSection = toGridSection(rawSection, currentLayoutId);
const section = persistedSection;
const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
if (!entry || !section || !currentLayout) return 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 columnCount =
section.kind === "container"
? section.width
: section.kind === "empty"
? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
: currentLayout.columnCount;
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
const contentRowCount = Math.max(
1,
...items.map((item) => item.yOffset + item.height),
...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
...renderedItems.map((item) => item.yOffset + item.height),
...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
);
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
@@ -170,8 +170,8 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
<SectionProvider
value={{
section,
items,
innerSections,
items: renderedItems,
innerSections: renderedInnerSections,
integrations,
columnCount,
maxRowCount,
@@ -183,23 +183,66 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
);
};
const toGridSection = (
section: Section | undefined,
layoutId: string,
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 === layoutId);
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,
...layout,
...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) {
@@ -172,8 +172,10 @@ export const SectionGrid = ({
<Box
{...canvasAttributes}
className={combineClasses(classes.viewport, className)}
w={logicalWidth}
h={`var(--board-grid-drag-height, ${logicalHeight}px)`}
style={{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
}}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}
-49
View File
@@ -235,55 +235,6 @@ describe("Onboarding", () => {
}
}, 120_000);
test("Credentials onboarding should be successful", async () => {
// Arrange
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync();
const competingContext = await browser.newContext();
const claimResponse = await competingContext.request.post(
`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`,
);
expect(claimResponse.status()).toBe(423);
await competingContext.close();
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
try {
await Promise.all([browser.close(), homarrContainer.stop()]);
} finally {
await cleanupSqliteDbAsync(sqlite);
}
}
}, 60_000);
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
+13 -6
View File
@@ -19,13 +19,18 @@ export type ContextSpecificItem = {
interface SpotlightContextProps {
items: ContextSpecificItem[];
}
interface SpotlightRegistrationContextProps {
registerItems: (key: string, results: ContextSpecificItem[]) => void;
unregisterItems: (key: string) => void;
}
const createSpotlightContext = (displayName: string) => {
const SpotlightContext = createContext<SpotlightContextProps | null>(null);
const SpotlightRegistrationContext = createContext<SpotlightRegistrationContextProps | null>(null);
SpotlightContext.displayName = displayName;
SpotlightRegistrationContext.displayName = `${displayName}Registration`;
const Provider = ({ children }: PropsWithChildren) => {
const [itemsMap, setItemsMap] = useState<Map<string, { items: ContextSpecificItem[]; count: number }>>(new Map());
@@ -51,16 +56,18 @@ const createSpotlightContext = (displayName: string) => {
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
return prevItems;
return newItemsMap;
});
}, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
const itemsContext = useMemo(() => ({ items }), [items]);
const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
return (
<SpotlightContext.Provider value={{ items, registerItems, unregisterItems }}>
{children}
</SpotlightContext.Provider>
<SpotlightRegistrationContext.Provider value={registration}>
<SpotlightContext.Provider value={itemsContext}>{children}</SpotlightContext.Provider>
</SpotlightRegistrationContext.Provider>
);
};
@@ -79,7 +86,7 @@ const createSpotlightContext = (displayName: string) => {
items: ContextSpecificItem[],
dependencyArray: DependencyList,
) => {
const context = useContext(SpotlightContext);
const context = useContext(SpotlightRegistrationContext);
if (!context) {
throw new Error(
@@ -97,7 +104,7 @@ const createSpotlightContext = (displayName: string) => {
context.unregisterItems(key);
};
// We ignore the results
}, [...dependencyArray, key]);
}, [...dependencyArray, context.registerItems, context.unregisterItems, key]);
};
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
+9 -2
View File
@@ -2710,7 +2710,7 @@
},
"remove": {
"title": "Remove item",
"message": "Are you sure you want to remove this item?"
"message": "Are you sure?"
},
"advancedFocus": {
"open": "Open advanced view",
@@ -2807,9 +2807,16 @@
"withBorder": {
"label": "Show border"
},
"customUrls": {
"label": "Custom links",
"description": "Add URLs directly without creating an App first"
},
"items": {
"label": "Bookmarks",
"add": "Add bookmark"
"apps": {
"label": "Apps",
"placeholder": "Select one or more apps"
}
}
}
},
+1 -1
View File
@@ -6,7 +6,7 @@ import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
options: Omit<WidgetOptionOfType<TKey>, "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
boardId?: string;
@@ -114,7 +114,7 @@ async function renderInput() {
<WidgetCustomWidgetSelectInput
kind="customApi"
property="definitionId"
options={{ withDescription: false }}
options={{ defaultValue: "", withDescription: false }}
initialOptions={{}}
/>,
);
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WidgetLocationInput } from "./widget-location-input";
const defaultLocation = { name: "Paris", latitude: 48.8566, longitude: 2.3522 };
const mocks = vi.hoisted(() => ({
values: {} as Record<string, unknown>,
setFieldValue: vi.fn(),
}));
vi.mock("@mantine/core", () => {
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
const Input = ({ label, value }: { label?: ReactNode; value?: string | number }) => (
<label>
{label}
<input value={value ?? ""} readOnly />
</label>
);
return {
Button: Container,
Fieldset: Container,
Group: Container,
NumberInput: Input,
Stack: Container,
TextInput: Input,
Tooltip: Container,
};
});
vi.mock("@homarr/modals", () => ({
createModal: () => ({ withOptions: () => null }),
useModalAction: () => ({ openModal: vi.fn() }),
}));
vi.mock("@homarr/translation/client", () => ({
useCurrentIntlLocale: () => "en",
useScopedI18n: () => (key: string) => key,
}));
vi.mock("./common", async (importOriginal) => ({
...(await importOriginal()),
useWidgetInputTranslation: () => (key: string) => key,
}));
vi.mock("./form", () => ({
useFormContext: () => ({
getInputProps: (path: string) => ({ value: mocks.values[path], onChange: vi.fn() }),
setFieldValue: mocks.setFieldValue,
clearFieldError: vi.fn(),
watch: vi.fn(),
}),
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
mocks.values = {};
mocks.setFieldValue.mockReset();
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
const renderInput = async () => {
await act(async () => {
root.render(
<WidgetLocationInput
kind="weather"
property="location"
options={{ defaultValue: defaultLocation }}
initialOptions={{}}
/>,
);
});
};
describe("location input initialization", () => {
it("hydrates a missing location with the default value", async () => {
await renderInput();
expect(mocks.setFieldValue).toHaveBeenCalledOnce();
expect(mocks.setFieldValue).toHaveBeenCalledWith("options.location", defaultLocation);
});
it("does not reset a transient empty coordinate after initialization", async () => {
mocks.values = {
"options.location": defaultLocation,
"options.location.name": defaultLocation.name,
"options.location.latitude": defaultLocation.latitude,
"options.location.longitude": defaultLocation.longitude,
};
await renderInput();
mocks.values = {
...mocks.values,
"options.location": { ...defaultLocation, latitude: "" },
"options.location.latitude": "",
};
await renderInput();
expect(mocks.setFieldValue).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback } from "react";
import { useCallback, useEffect, useRef } from "react";
import {
ActionIcon,
Alert,
@@ -29,14 +29,26 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"location">) => {
export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
const inputProps = form.getInputProps(`options.${property}`);
const value = inputProps.value as OptionLocation;
const fieldPath = `options.${property}`;
const inputProps = form.getInputProps(fieldPath);
const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue;
const nameInputProps = form.getInputProps(`options.${property}.name`);
const latitudeInputProps = form.getInputProps(`options.${property}.latitude`);
const longitudeInputProps = form.getInputProps(`options.${property}.longitude`);
const selectionEnabled = value.name.length > 1;
const initializedFieldPathRef = useRef<string | null>(null);
useEffect(() => {
if (initializedFieldPathRef.current === fieldPath) return;
initializedFieldPathRef.current = fieldPath;
if (isOptionLocation(inputProps.value)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [fieldPath, form, inputProps.value, options.defaultValue]);
const handleChange = inputProps.onChange as LocationOnChange;
const unknownLocation = tLocation("unknownLocation");
@@ -73,7 +85,12 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
<Fieldset legend={t("label")}>
<Stack gap="xs">
<Group wrap="nowrap" align="end">
<TextInput w="100%" label={tLocation("query")} {...form.getInputProps(`options.${property}.name`)} />
<TextInput
w="100%"
label={tLocation("query")}
{...nameInputProps}
value={nameInputProps.value ?? value.name}
/>
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
<div>
<Button
@@ -93,13 +110,15 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
decimalScale={5}
label={tLocation("latitude")}
hideControls
{...form.getInputProps(`options.${property}.latitude`)}
{...latitudeInputProps}
value={latitudeInputProps.value ?? value.latitude}
/>
<NumberInput
decimalScale={5}
label={tLocation("longitude")}
hideControls
{...form.getInputProps(`options.${property}.longitude`)}
{...longitudeInputProps}
value={longitudeInputProps.value ?? value.longitude}
/>
</Group>
</Stack>
@@ -107,6 +126,16 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
);
};
const isOptionLocation = (value: unknown): value is OptionLocation =>
typeof value === "object" &&
value !== null &&
"name" in value &&
typeof value.name === "string" &&
"latitude" in value &&
typeof value.latitude === "number" &&
"longitude" in value &&
typeof value.longitude === "number";
type LocationOnChange = (
location: Pick<OptionLocation, "name"> & {
latitude: OptionLocation["latitude"] | "";
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
@@ -18,10 +18,16 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
const [search, setSearch] = useState("");
const form = useFormContext();
const inputProps = form.getInputProps(`options.${property}`);
const values = inputProps.value as string[];
const fieldPath = `options.${property}`;
const inputProps = form.getInputProps(fieldPath);
const values = Array.isArray(inputProps.value) ? (inputProps.value as string[]) : options.defaultValue;
const onChange = inputProps.onChange as (values: string[]) => void;
useEffect(() => {
if (Array.isArray(inputProps.value)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [fieldPath, form, inputProps.value, options.defaultValue]);
const handleRemove = (optionIndex: number) => {
onChange(values.filter((_, index) => index !== optionIndex));
};
@@ -31,8 +31,14 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
}: CommonWidgetInputProps<"sortableItemList">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]);
const values = form.values.options[property] as TOptionValue[];
const fieldPath = `options.${property}`;
const initialValues = useMemo(
() =>
Array.isArray(initialOptions[property]) ? (initialOptions[property] as TOptionValue[]) : options.defaultValue,
[initialOptions, options.defaultValue, property],
);
const currentValue = form.values.options[property];
const values = Array.isArray(currentValue) ? (currentValue as TOptionValue[]) : options.defaultValue;
const { data, isLoading, error } = options.useData(initialValues);
const dataMap = useMemo(
() => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])),
@@ -52,6 +58,11 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
const getIndex = (id: TOptionValue) => values.indexOf(id);
const activeIndex = activeId ? getIndex(activeId) : -1;
useEffect(() => {
if (Array.isArray(currentValue)) return;
form.setFieldValue(fieldPath, options.defaultValue);
}, [currentValue, fieldPath, form, options.defaultValue]);
useEffect(() => {
if (!activeId) {
isFirstAnnouncement.current = true;
@@ -69,22 +80,39 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
[tempMap, dataMap],
);
const updateItems = (callback: (prev: TOptionValue[]) => TOptionValue[]) => {
form.setFieldValue(`options.${property}`, callback);
};
const updateItems = useCallback(
(callback: (prev: TOptionValue[]) => TOptionValue[]) => {
form.setFieldValue(fieldPath, callback);
},
[fieldPath, form],
);
const addItem = (item: TItem) => {
setTempMap((prev) => {
prev.set(options.uniqueIdentifier(item) as TOptionValue, item);
return prev;
const next = new Map(prev);
next.set(options.uniqueIdentifier(item) as TOptionValue, item);
return next;
});
updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]);
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
};
const removeItem = useCallback(
(value: TOptionValue) => {
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
setTempMap((previous) => {
if (!previous.has(value)) return previous;
const next = new Map(previous);
next.delete(value);
return next;
});
},
[updateItems],
);
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton addItem={addItem} values={values} />
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
<DndContext
sensors={sensors}
@@ -114,19 +142,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
<React.Fragment>
{values.map((value, index) => {
const item = getItem(value);
const removeItem = () => {
form.setValues((previous) => {
const previousValues = previous.options?.[property] as TOptionValue[];
return {
...previous,
options: {
...previous.options,
[property]: previousValues.filter((id) => id !== value),
},
};
});
};
if (!item) {
return null;
}
@@ -137,7 +152,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
id={value}
index={index}
item={item}
removeItem={removeItem}
removeItem={() => removeItem(value)}
options={options}
/>
);
@@ -1,18 +1,26 @@
.clickableCard {
position: relative;
transition: background-color 150ms;
cursor: pointer;
}
.clickableCard:hover,
.clickableCard:focus-visible {
.clickableCard:focus-within {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
.clickableCard:focus-visible {
.clickableCard:focus-within {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
.cardAction {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
}
@media (prefers-reduced-motion: reduce) {
.clickableCard {
transition: none;
@@ -1,7 +1,7 @@
"use client";
import type { MantineSize } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core";
import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core";
import {
Activity,
Battery,
@@ -365,14 +365,10 @@ const SystemCard = ({
return (
<Card
component="button"
type="button"
disabled={!onClick}
padding={size.cardPadding}
radius={itemRadius}
bg="transparent"
h="100%"
onClick={onClick}
className={onClick ? classes.clickableCard : undefined}
style={{
overflow: "hidden",
@@ -385,6 +381,14 @@ const SystemCard = ({
width: "100%",
}}
>
{onClick && (
<UnstyledButton
type="button"
className={classes.cardAction}
aria-label={system.name}
onClick={onClick}
/>
)}
<Group gap="xs" mb={2}>
<Badge
size={size.badgeSize}
@@ -1,4 +1,6 @@
.target {
position: relative;
z-index: 2;
min-width: 24px;
min-height: 24px;
}
+69 -20
View File
@@ -1,30 +1,79 @@
"use client";
import { Button } from "@mantine/core";
import type { MultiSelectProps } from "@mantine/core";
import { Group, Loader, MultiSelect } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { SortableItemListInput } from "../options";
import { AppSelectModal } from "./app-select-modal";
export const BookmarkAddButton: SortableItemListInput<
{
name: string;
description: string | null;
id: string;
iconUrl: string;
href: string | null;
pingUrl: string | null;
},
string
>["AddButton"] = ({ addItem, values }) => {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
type SelectableApp = RouterOutputs["app"]["selectable"][number];
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
addItem,
removeItem,
values,
}) => {
const t = useScopedI18n("widget.bookmarks.option.items.apps");
const tCommon = useScopedI18n("common");
const tSelect = useScopedI18n("app.action.select");
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
const appsById = new Map(apps.map((app) => [app.id, app]));
const handleChange = (nextValues: string[]) => {
const nextIds = new Set(nextValues);
for (const value of values) {
if (!nextIds.has(value)) removeItem(value);
}
const currentIds = new Set(values);
for (const value of nextValues) {
if (currentIds.has(value)) continue;
const app = appsById.get(value);
if (app) addItem(app);
}
};
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
<MultiSelect
label={t("label")}
placeholder={t("placeholder")}
value={values}
onChange={handleChange}
searchable
clearable
clearSearchOnChange
hidePickedOptions
disabled={isPending || Boolean(error)}
error={error ? tCommon("error") : undefined}
rightSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={tSelect("notFound")}
renderOption={renderAppOption}
data={apps.map((app) => ({
value: app.id,
label: app.name,
iconUrl: app.iconUrl,
}))}
/>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt="" />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
@@ -1,109 +0,0 @@
"use client";
import { memo, useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentAppIds: string[];
onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise<void>;
confirmLabel?: string;
}
interface AppSelectFormType {
id: string;
}
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<AppSelectFormType>();
const handleSubmitAsync = async (values: AppSelectFormType) => {
const currentApp = apps?.find((app) => app.id === values.id);
if (!currentApp) return;
setLoading(true);
await innerProps.onSelect(currentApp);
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentApp = apps?.find((app) => app.id === form.values.id);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("id")}
label={t("app.action.select.label")}
searchable
clearable
data-autofocus
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("app.action.select.notFound")}
renderOption={renderSelectOption}
limit={5}
data={
apps
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
/>
<ModalFormFooter onCancel={actions.closeModal} submitLabel={confirmLabel} loading={loading} />
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.label"),
size: modalSizeForm,
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);
+63 -19
View File
@@ -1,5 +1,6 @@
"use client";
import type { CSSProperties } from "react";
import { useMemo } from "react";
import {
Anchor,
@@ -14,9 +15,9 @@ import {
Title,
UnstyledButton,
} from "@mantine/core";
import { IconLink } from "@tabler/icons-react";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
@@ -29,6 +30,14 @@ import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
export interface BookmarkDisplayItem {
id: string;
name: string;
description: string | null;
iconUrl?: string;
href: string | null;
}
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
@@ -67,14 +76,19 @@ export default function BookmarksWidget({
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const data =
const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
select(selectedApps) {
return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
const customLinks = useMemo(
() => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
[options.customUrls],
);
const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
@@ -85,7 +99,7 @@ export default function BookmarksWidget({
{
id: app.id,
name: app.name,
icon: app.iconUrl,
icon: app.iconUrl ?? IconLink,
interaction() {
return {
type: "link",
@@ -161,7 +175,7 @@ export default function BookmarksWidget({
}
interface FlexLayoutProps {
data: RouterOutputs["app"]["byIds"];
data: BookmarkDisplayItem[];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
@@ -235,7 +249,7 @@ const FlexLayout = ({
};
interface AdvancedBookmarksLayoutProps {
data: RouterOutputs["app"]["byIds"];
data: BookmarkDisplayItem[];
width: number;
openNewTab: boolean;
withBorder: boolean;
@@ -250,6 +264,19 @@ export const getBookmarkHostname = (href: string | null): string | undefined =>
return safeHref ? new URL(safeHref).hostname : undefined;
};
export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
const normalizedUrl = url.trim();
const href = getSafeAppHref(normalizedUrl);
if (!href) return null;
return {
id: `custom-link:${normalizedUrl}`,
name: getBookmarkHostname(href) ?? normalizedUrl,
description: null,
href,
};
};
const AdvancedBookmarksLayout = ({
data,
width,
@@ -275,10 +302,9 @@ const AdvancedBookmarksLayout = ({
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
<BookmarkIcon
app={app}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
@@ -306,7 +332,7 @@ const AdvancedBookmarksLayout = ({
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
data: BookmarkDisplayItem[];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
@@ -392,7 +418,7 @@ const VerticalItem = ({
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
@@ -406,10 +432,9 @@ const VerticalItem = ({
</Text>
)}
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
<BookmarkIcon
app={app}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
@@ -437,7 +462,7 @@ const HorizontalItem = ({
hideHostname,
hasIconColor,
}: {
app: RouterOutputs["app"]["byIds"][number];
app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
@@ -446,10 +471,9 @@ const HorizontalItem = ({
return (
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
{!hideIcon && (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
<BookmarkIcon
app={app}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{
overflow: "auto",
@@ -479,3 +503,23 @@ const HorizontalItem = ({
</Group>
);
};
interface BookmarkIconProps {
app: BookmarkDisplayItem;
hasColor: boolean;
className?: string;
style?: CSSProperties;
}
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
app.iconUrl ? (
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasColor}
alt={app.name}
className={className}
style={style}
/>
) : (
<IconLink aria-label={app.name} className={className} style={style} />
);
+7 -1
View File
@@ -1,9 +1,11 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getSafeAppHref } from "../common/application-url";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
@@ -27,7 +29,11 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
customUrls: factory.multiText({
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
withDescription: true,
}),
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
+22 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "vitest";
import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
import {
createCustomBookmark,
getAdvancedBookmarkColumns,
getBookmarkHostname,
getCompactBookmarkLayout,
} from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
@@ -30,4 +35,20 @@ describe("bookmark display helpers", () => {
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
});
test("creates standalone bookmarks without app records", () => {
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
id: "custom-link:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
id: "custom-link:https://homarr.dev/docs",
name: "homarr.dev",
description: null,
href: "https://homarr.dev/docs",
});
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
});
});
+2 -2
View File
@@ -755,7 +755,7 @@ export default function DownloadClientsWidget({
<Stack gap={0} h="100%" style={{ overflow: "hidden" }}>
{statsDisplay.visible && (
<GlobalStatsBar
key={displayMode}
key={`stats-${displayMode}`}
queueStats={queueStats}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
@@ -803,7 +803,7 @@ export default function DownloadClientsWidget({
{(isAdvanced || height >= 96) && (
<WidgetFooter
key={displayMode}
key={`footer-${displayMode}`}
clients={clients}
totalSpeed={totalSpeed}
totalUpSpeed={totalUpSpeed}
+5 -1
View File
@@ -32,7 +32,11 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
CommonInput<TOptionValue[]>,
"withDescription"
> {
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
AddButton: (props: {
addItem: (item: TItem) => void;
removeItem: (value: TOptionValue) => void;
values: TOptionValue[];
}) => React.ReactNode;
ItemComponent: (props: {
item: TItem;
removeItem: () => void;