diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx
index 30b61fcf6..07765cf11 100644
--- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx
+++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx
@@ -6,19 +6,19 @@ tags:
Homarr has the following keyboard shortcuts:
-| Shortcut | Description |
-| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
-| `MOD` + `K` | Opens the search bar |
-| `MOD` + `J` | Toggles Dark Mode |
-| `MOD` + `E` | Toggles Edit Mode |
-| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
-| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
-| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
+| Shortcut | Description |
+| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
+| `MOD` + `K` | Opens the search bar |
+| `MOD` + `J` | Toggles Dark Mode |
+| `MOD` + `E` | Toggles Edit Mode |
+| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
+| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
+| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Widget advanced view
-Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
-`Shift` + `Enter`, or use its menu, to keep the advanced view open.
+Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
+on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
## Board editing
diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx
index d7485127f..bcf9b92fa 100644
--- a/apps/docs/docs/getting-started/after-the-installation.mdx
+++ b/apps/docs/docs/getting-started/after-the-installation.mdx
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
-stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
+stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx
index 5c1407572..92d709e3c 100644
--- a/apps/docs/docs/management/boards/index.mdx
+++ b/apps/docs/docs/management/boards/index.mdx
@@ -210,7 +210,8 @@ Containers as destinations before the widget is created, so the common flow does
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
-selection. A move is applied only when every selected item fits in the destination.
+selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
+selected item fits in the destination.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -329,8 +330,9 @@ This will disable the status check for apps.
Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
- remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
- open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
+ remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
+ keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
+ instead.
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
not use this widget menu, and available controls follow your board and integration permissions.
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
index efd9be8fb..8d41652ee 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
@@ -1,11 +1,13 @@
"use client";
import { Box, Paper, Stack, Text } from "@mantine/core";
+import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
+import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
@@ -33,6 +35,7 @@ export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const tPreview = useScopedI18n("board.setting.section.layout.preview");
+ const tTips = useScopedI18n("tips");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
@@ -129,6 +132,16 @@ export const ClientBoard = () => {
+ , variant: "light" }}
+ >
+ {tTips("multiSelectApps")}
+
diff --git a/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx
index d3fad372a..766b166b5 100644
--- a/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx
+++ b/apps/nextjs/src/components/board/advanced-focus/context.spec.tsx
@@ -139,6 +139,49 @@ describe("BoardAdvancedFocusProvider preview interaction", () => {
expect(focus.active).toBeNull();
});
+ test("promotes a Shift preview to a persistent view with Shift+Control", async () => {
+ await startPreview();
+ await act(async () => vi.advanceTimersByTime(500));
+ expect(focus.active?.activation).toBe("preview");
+
+ await act(async () =>
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Control", ctrlKey: true, shiftKey: true, cancelable: true }),
+ ),
+ );
+ expect(focus.active?.activation).toBe("manual");
+
+ await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })));
+ expect(focus.active?.activation).toBe("manual");
+ });
+
+ test("promotes a Shift preview to a persistent view with Shift+Meta", async () => {
+ await startPreview();
+ await act(async () => vi.advanceTimersByTime(500));
+
+ await act(async () =>
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Meta", metaKey: true, shiftKey: true, cancelable: true }),
+ ),
+ );
+ expect(focus.active?.activation).toBe("manual");
+ });
+
+ test("opens a persistent view when the keep-open modifiers are held before the delay", async () => {
+ const source = host.querySelector("[data-testid='source']");
+ if (!source) throw new Error("Expected source fixture");
+ await act(async () => {
+ focus.hover("widget-1", source);
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }));
+ window.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Shift", ctrlKey: true, shiftKey: true, cancelable: true }),
+ );
+ });
+
+ await act(async () => vi.advanceTimersByTime(500));
+ expect(focus.active?.activation).toBe("manual");
+ });
+
test("cancels the hold feedback and activation when the source is left early", async () => {
await startPreview();
diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx
index bc2028961..cc218e4c4 100644
--- a/apps/nextjs/src/components/board/advanced-focus/context.tsx
+++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx
@@ -55,6 +55,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const activeRef = useRef(null);
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
const shiftHeldRef = useRef(false);
+ const keepOpenHeldRef = useRef(false);
const hoverTimerRef = useRef | null>(null);
const previewLeaveTimerRef = useRef | null>(null);
const closeTimerRef = useRef | null>(null);
@@ -151,10 +152,31 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
hoverTimerRef.current = setTimeout(() => {
hoverTimerRef.current = null;
setIsHoldPending(false);
- open(hovered.itemId, hovered.source, { activation: "preview" });
+ const activation = keepOpenHeldRef.current ? "manual" : "preview";
+ open(hovered.itemId, hovered.source, {
+ activation,
+ autofocusClose: activation === "manual",
+ restoreFocusTarget: hovered.source,
+ });
}, HOVER_DELAY_MS);
}, [cancelHoverTimer, open]);
+ const promotePreviewToManual = useCallback(() => {
+ const current = activeRef.current;
+ if (!current || current.activation !== "preview") return false;
+ cancelHoverTimer();
+ cancelPreviewLeaveTimer();
+ cancelCloseTimer();
+ updateActive({
+ ...current,
+ activation: "manual",
+ autofocusClose: true,
+ restorePreviewFocus: false,
+ phase: "visible",
+ });
+ return true;
+ }, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive]);
+
const hover = useCallback(
(itemId: string, source: HTMLElement) => {
hoveredRef.current = { itemId, source };
@@ -268,19 +290,37 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return;
}
if (isEditMode) return;
- if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
+ if (event.repeat || isEditableTarget(event.target)) return;
+
+ const isKeepOpenModifier = event.key === "Control" || event.key === "Meta";
+ const hasKeepOpenShortcut = event.shiftKey && (event.ctrlKey || event.metaKey);
+ if (isKeepOpenModifier && hasKeepOpenShortcut) {
+ event.preventDefault();
+ keepOpenHeldRef.current = true;
+ if (!promotePreviewToManual()) startHoverTimer();
+ return;
+ }
+
+ if (event.key !== "Shift") return;
if (activeRef.current?.activation === "manual") return;
shiftHeldRef.current = true;
+ keepOpenHeldRef.current = hasKeepOpenShortcut;
startHoverTimer();
};
const handleKeyUp = (event: KeyboardEvent) => {
+ if (event.key === "Control" || event.key === "Meta") {
+ keepOpenHeldRef.current = false;
+ return;
+ }
if (event.key !== "Shift") return;
shiftHeldRef.current = false;
+ keepOpenHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current?.activation === "preview") close(false);
};
const handleBlur = () => {
shiftHeldRef.current = false;
+ keepOpenHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current?.activation === "preview") close(false);
};
@@ -296,7 +336,15 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
cancelPreviewLeaveTimer();
cancelCloseTimer();
};
- }, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]);
+ }, [
+ cancelCloseTimer,
+ cancelHoverTimer,
+ cancelPreviewLeaveTimer,
+ close,
+ isEditMode,
+ promotePreviewToManual,
+ startHoverTimer,
+ ]);
useEffect(() => {
if (isEditMode && activeRef.current) close(false);
diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts
index 56ad965e0..9b0f08d23 100644
--- a/apps/nextjs/src/components/board/items/actions/create-item.ts
+++ b/apps/nextjs/src/components/board/items/actions/create-item.ts
@@ -18,10 +18,11 @@ export interface CreateItemInput {
integrationIds?: string[];
targetSectionId?: string;
advancedOptions?: BoardItemAdvancedOptions;
+ size?: { width: number; height: number };
}
export const createItemCallback =
- ({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
+ ({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions, size }: CreateItemInput) =>
(previous: Board): Board => {
const firstCanvasSection = previous.sections
.filter(
@@ -40,10 +41,10 @@ export const createItemCallback =
if (!targetSection) return previous;
- let itemLayouts = createItemLayouts(previous, targetSection, kind);
+ let itemLayouts = createItemLayouts(previous, targetSection, kind, size);
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
targetSection = firstCanvasSection;
- itemLayouts = createItemLayouts(previous, targetSection, kind);
+ itemLayouts = createItemLayouts(previous, targetSection, kind, size);
}
if (itemLayouts.length === 0) return previous;
const widget = {
@@ -74,9 +75,10 @@ const createItemLayouts = (
board: Board,
currentSection: EmptySection | ContainerSection,
kind: WidgetKind,
+ requestedSize?: { width: number; height: number },
): ItemLayout[] => {
const layouts = getBoardLayouts(board);
- const itemSize = getWidgetItemSize(kind);
+ const itemSize = requestedSize ?? getWidgetItemSize(kind);
return layouts.flatMap((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index 368f86207..abb982fa1 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -382,7 +382,7 @@ const LoadedBoardItemContent = ({
className={itemContentClasses.advancedFocusTrigger}
aria-expanded={isAdvanced}
aria-controls={isAdvanced ? advancedViewId : undefined}
- aria-keyshortcuts="Shift+Enter"
+ aria-keyshortcuts="Shift+Control Shift+Meta"
data-advanced-focus-trigger
onClick={openAdvancedView}
>
diff --git a/apps/nextjs/src/components/board/selection/board-item-clipboard.ts b/apps/nextjs/src/components/board/selection/board-item-clipboard.ts
new file mode 100644
index 000000000..e88c083c4
--- /dev/null
+++ b/apps/nextjs/src/components/board/selection/board-item-clipboard.ts
@@ -0,0 +1,63 @@
+import { z } from "zod/v4";
+
+import { widgetKinds } from "@homarr/definitions";
+import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
+
+import type { Item } from "~/app/[locale]/boards/_types";
+
+const clipboardPrefix = "homarr-board-items:v1\n";
+
+const clipboardPayloadSchema = z.object({
+ version: z.literal(1),
+ items: z
+ .array(
+ z.object({
+ kind: z.enum(widgetKinds),
+ options: z.record(z.string(), z.unknown()),
+ integrationIds: z.array(z.string()),
+ advancedOptions: itemAdvancedOptionsSchema,
+ size: z.object({
+ width: z.number().int().min(1).max(64),
+ height: z.number().int().min(1).max(64),
+ }),
+ }),
+ )
+ .min(1)
+ .max(100),
+});
+
+export type BoardItemClipboardPayload = z.infer;
+
+export const serializeBoardItemsForClipboard = (items: readonly Item[], layoutId: string): string => {
+ const clipboardItems = items.flatMap((item) => {
+ const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
+ if (!layout) return [];
+
+ return [
+ {
+ kind: item.kind,
+ options: item.options,
+ integrationIds: item.integrationIds,
+ advancedOptions: item.advancedOptions,
+ size: { width: layout.width, height: layout.height },
+ },
+ ];
+ });
+
+ if (clipboardItems.length === 0) throw new Error("No selected items are available in the current layout");
+
+ return `${clipboardPrefix}${JSON.stringify({ version: 1, items: clipboardItems })}`;
+};
+
+export const isBoardItemClipboardText = (value: string): boolean => value.startsWith(clipboardPrefix);
+
+export const parseBoardItemClipboard = (value: string): BoardItemClipboardPayload | null => {
+ if (!isBoardItemClipboardText(value)) return null;
+
+ try {
+ const result = clipboardPayloadSchema.safeParse(JSON.parse(value.slice(clipboardPrefix.length)));
+ return result.success ? result.data : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/apps/nextjs/src/components/board/selection/board-selection-context.tsx b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
index c786faff1..8097aae9f 100644
--- a/apps/nextjs/src/components/board/selection/board-selection-context.tsx
+++ b/apps/nextjs/src/components/board/selection/board-selection-context.tsx
@@ -8,19 +8,28 @@ import { notifications } from "@mantine/notifications";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
+import { createId } from "@homarr/common";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
+import { createItemCallback } from "~/components/board/items/actions/create-item";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { playPopSound, playTrashSound } from "../audio/board-sounds";
+import {
+ isBoardItemClipboardText,
+ parseBoardItemClipboard,
+ serializeBoardItemsForClipboard,
+} from "./board-item-clipboard";
interface BoardSelectionContextValue {
selectedItemIds: Set;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
+ copySelectedItems: () => Promise;
+ pasteItems: () => Promise;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void;
}
@@ -37,7 +46,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const tSelection = useScopedI18n("item.selection");
const persistBoard = useCallback(
- (updater: (previous: typeof board) => typeof board) => {
+ (updater: (previous: typeof board) => typeof board) =>
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
@@ -45,8 +54,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
message: tSelection("saveErrorMessage"),
});
},
- });
- },
+ }),
[tSelection, updateAndPersistBoard],
);
@@ -90,6 +98,95 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
+ const copySelectedItems = useCallback(async () => {
+ const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
+ if (selectedItems.length === 0) return;
+
+ try {
+ const value = serializeBoardItemsForClipboard(selectedItems, currentLayoutId);
+ await navigator.clipboard.writeText(value);
+ showSuccessNotification({
+ title: t("common.success"),
+ message: tSelection("copied", { count: selectedItems.length }),
+ });
+ } catch {
+ showErrorNotification({
+ title: t("common.error"),
+ message: tSelection("copyError"),
+ });
+ }
+ }, [board.items, currentLayoutId, selectedItemIds, t, tSelection]);
+
+ const pasteItemsFromText = useCallback(
+ (value: string) => {
+ const payload = parseBoardItemClipboard(value);
+ if (!payload) {
+ showErrorNotification({ title: t("common.error"), message: tSelection("pasteInvalid") });
+ return;
+ }
+
+ const pastedIds = payload.items.map(() => createId());
+ let didPasteAll = false;
+ persistBoard((previous) => {
+ let next = previous;
+ try {
+ payload.items.forEach((item, index) => {
+ const id = pastedIds[index];
+ if (!id) throw new Error("Missing pasted item ID");
+ next = createItemCallback({ ...item, id })(next);
+ });
+ } catch {
+ return previous;
+ }
+
+ didPasteAll = next.items.length === previous.items.length + payload.items.length;
+ return didPasteAll ? next : previous;
+ });
+
+ if (!didPasteAll) {
+ showErrorNotification({ title: t("common.error"), message: tSelection("pasteUnavailable") });
+ return;
+ }
+
+ setSelectedItemIds(new Set(pastedIds));
+ playPopSound();
+ showSuccessNotification({
+ title: t("common.success"),
+ message: tSelection("pasted", { count: pastedIds.length }),
+ });
+ },
+ [persistBoard, t, tSelection],
+ );
+
+ const pasteItems = useCallback(async () => {
+ try {
+ pasteItemsFromText(await navigator.clipboard.readText());
+ } catch {
+ showErrorNotification({ title: t("common.error"), message: tSelection("pasteError") });
+ }
+ }, [pasteItemsFromText, t, tSelection]);
+
+ useEffect(() => {
+ if (!isEditMode) return;
+
+ const handlePaste = (event: ClipboardEvent) => {
+ const target = event.target;
+ if (
+ target instanceof Element &&
+ target.closest("input, textarea, select, [contenteditable='true'], [role='textbox']")
+ )
+ return;
+
+ const value = event.clipboardData?.getData("text/plain") ?? "";
+ if (!isBoardItemClipboardText(value)) return;
+ event.preventDefault();
+ pasteItemsFromText(value);
+ };
+
+ window.addEventListener("paste", handlePaste);
+ return () => window.removeEventListener("paste", handlePaste);
+ }, [isEditMode, pasteItemsFromText]);
+
const removeSelectedItems = useCallback(() => {
if (selectedItemIds.size === 0) return;
@@ -252,10 +349,21 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
toggleSelectItem,
clearSelection,
isSelected,
+ copySelectedItems,
+ pasteItems,
removeSelectedItems,
moveSelectedItemsToSection,
}),
- [clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
+ [
+ clearSelection,
+ copySelectedItems,
+ isSelected,
+ moveSelectedItemsToSection,
+ pasteItems,
+ removeSelectedItems,
+ selectedItemIds,
+ toggleSelectItem,
+ ],
);
return {children};
diff --git a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
index 0f9bbc3c4..c1d2df844 100644
--- a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
+++ b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx
@@ -15,7 +15,7 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
-import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
+import { IconCheck, IconClipboard, IconCopy, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
@@ -31,7 +31,14 @@ const MAX_AVATARS_DISPLAYED = 5;
export const BoardSelectionToolbar = () => {
const [isEditMode] = useEditMode();
- const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
+ const {
+ selectedItemIds,
+ clearSelection,
+ copySelectedItems,
+ pasteItems,
+ removeSelectedItems,
+ moveSelectedItemsToSection,
+ } = useBoardSelection();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
@@ -201,14 +208,32 @@ export const BoardSelectionToolbar = () => {
-
- {t("app.action.select.multiSelectTip")}
-
-
{/* Actions */}
+ }
+ onClick={() => void copySelectedItems()}
+ >
+ {tSelection("copy")}
+
+
+
+ }
+ onClick={() => void pasteItems()}
+ >
+ {tSelection("paste")}
+
+
+
{placementOptions.length > 1 && (