feat(ui): add situational tips system (#6648)
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user