Files
homarr/apps/nextjs/src/components/board/selection/board-selection-context.tsx
T
Thomas 19760e5722 fix: restore translucent dashboard styling (#6664)
* fix: restore translucent dashboard styling

* fix: restore dashboard widget transparency

* fix: address dashboard review feedback

* fix: apply board opacity to containers

* fix: flatten demo board container

* fix: default board roundness to sm

* fix: restore assistant widget transparency

* Revert "fix: default board roundness to sm"

This reverts commit c235cbdf2b.

* fix: address remaining dashboard review comments

* test: align grid geometry expectations
2026-08-22 15:51:43 +02:00

379 lines
13 KiB
TypeScript

"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Button, Group, Text } from "@mantine/core";
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 } 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, maxRowCount?: number | null) => void;
}
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { updateAndPersistBoard } = usePersistBoard(board);
const tCommon = useI18n("common");
const tSelection = useI18n("item.selection");
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) =>
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: tSelection("saveErrorTitle"),
message: tSelection("saveErrorMessage"),
});
},
}),
[tSelection, updateAndPersistBoard],
);
// Clear selection if leaving edit mode
useEffect(() => {
if (!isEditMode) {
setSelectedItemIds(new Set());
}
}, [isEditMode]);
// Clear selection on Escape key
useEffect(() => {
if (selectedItemIds.size === 0) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setSelectedItemIds(new Set());
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedItemIds.size]);
const toggleSelectItem = useCallback((id: string) => {
setSelectedItemIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
playPopSound();
}, []);
const clearSelection = useCallback(() => {
setSelectedItemIds(new Set());
}, []);
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: tCommon("success"),
message: tSelection("copied", { count: selectedItems.length }),
});
} catch {
showErrorNotification({
title: tCommon("error"),
message: tSelection("copyError"),
});
}
}, [board.items, currentLayoutId, selectedItemIds, tCommon, tSelection]);
const pasteItemsFromText = useCallback(
(value: string) => {
const payload = parseBoardItemClipboard(value);
if (!payload) {
showErrorNotification({ title: tCommon("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: tCommon("error"), message: tSelection("pasteUnavailable") });
return;
}
setSelectedItemIds(new Set(pastedIds));
playPopSound();
showSuccessNotification({
title: tCommon("success"),
message: tSelection("pasted", { count: pastedIds.length }),
});
},
[persistBoard, tCommon, tSelection],
);
const pasteItems = useCallback(async () => {
try {
pasteItemsFromText(await navigator.clipboard.readText());
} catch {
showErrorNotification({ title: tCommon("error"), message: tSelection("pasteError") });
}
}, [pasteItemsFromText, tCommon, 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;
const idsToRemove = Array.from(selectedItemIds);
const count = idsToRemove.length;
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
playTrashSound();
clearSelection();
persistBoard((previous) => ({
...previous,
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
}));
const notificationId = `batch-remove-${Date.now()}`;
showSuccessNotification({
id: notificationId,
title: tCommon("success"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{tSelection("removed", { count })}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
persistBoard((previous) => ({
...previous,
items: [...previous.items, ...removedItems],
}));
notifications.hide(notificationId);
}}
>
{tCommon("action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
}, [board.items, clearSelection, persistBoard, selectedItemIds, tCommon, tSelection]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string, maxRowCount?: number | null) => {
if (selectedItemIds.size === 0) return;
const idsToMove = new Set(selectedItemIds);
let didMoveAll = false;
persistBoard((previous) => {
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
if (!targetSection || !boardLayout) return previous;
const containerLayout =
targetSection.kind === "container"
? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
: undefined;
const columnCount =
targetSection.kind === "container"
? (containerLayout?.width ?? 0)
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
const rowCount = containerLayout?.height ?? maxRowCount ?? 9999;
if (columnCount === 0 || rowCount === 0) return previous;
const existingContainers = previous.sections
.filter((section): section is ContainerSection => section.kind === "container")
.flatMap((section) => {
const layout = section.layouts.find(
(candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
);
return layout
? [
{
id: section.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
},
]
: [];
});
const existingItems = previous.items.flatMap((item) => {
const layout = item.layouts.find(
(candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
);
return layout
? [
{
id: item.id,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
width: layout.width,
height: layout.height,
},
]
: [];
});
const occupiedPositions = [...existingContainers, ...existingItems];
const movedLayouts = new Map<string, ItemLayout>();
for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
if (!currentItemLayout) continue;
const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
const previousPosition =
previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
const { width, height } = currentItemLayout;
const emptyPosition =
width <= columnCount && height <= rowCount
? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
: undefined;
if (!emptyPosition) {
if (previousPosition) occupiedPositions.push(previousPosition);
continue;
}
const nextLayout: ItemLayout = {
...currentItemLayout,
sectionId: targetSectionId,
xOffset: emptyPosition.xOffset,
yOffset: emptyPosition.yOffset,
};
movedLayouts.set(item.id, nextLayout);
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
}
if (movedLayouts.size !== idsToMove.size) return previous;
didMoveAll = true;
return {
...previous,
items: previous.items.map((item) => {
const movedLayout = movedLayouts.get(item.id);
if (!movedLayout) return item;
return {
...item,
layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
};
}),
};
});
if (didMoveAll) {
clearSelection();
} else {
showErrorNotification({
title: tSelection("moveUnavailableTitle"),
message: tSelection("moveUnavailableMessage"),
});
}
},
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
);
const value = useMemo(
() => ({
selectedItemIds,
toggleSelectItem,
clearSelection,
isSelected,
copySelectedItems,
pasteItems,
removeSelectedItems,
moveSelectedItemsToSection,
}),
[
clearSelection,
copySelectedItems,
isSelected,
moveSelectedItemsToSection,
pasteItems,
removeSelectedItems,
selectedItemIds,
toggleSelectItem,
],
);
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
};
export const useBoardSelection = () => {
const context = useContext(BoardSelectionContext);
if (!context) {
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
}
return context;
};