feat(ui): add situational tips system (#6648)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 18:58:52 +02:00
1 parent 3c2f95d529
commit f0f9539466
20 files changed
+694 -493

No files matched your search

@@ -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<typeof clipboardPayloadSchema>;
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;
}
};
@@ -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<string>;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
copySelectedItems: () => Promise<void>;
pasteItems: () => Promise<void>;
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 <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
@@ -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 = () => {
</HoverCard.Dropdown>
</HoverCard>
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
{t("app.action.select.multiSelectTip")}
</Text>
<Divider orientation="vertical" />
{/* Actions */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Button
size="xs"
variant="light"
color="blue"
leftSection={<IconCopy size={15} />}
onClick={() => void copySelectedItems()}
>
{tSelection("copy")}
</Button>
<Tooltip label={tSelection("pasteShortcut")} withArrow>
<Button
size="xs"
variant="light"
color="blue"
leftSection={<IconClipboard size={15} />}
onClick={() => void pasteItems()}
>
{tSelection("paste")}
</Button>
</Tooltip>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>