feat(board): add Cmd/Ctrl multi-item selection, floating action bar, and procedural delete audio synthesis

This commit is contained in:
Thomas Camlong committed 2026-08-21 01:01:09 +02:00
1 parent 439cb7f497
commit c94dc7a2e8
5 files changed
+486 -61

No files matched your search

@@ -23,6 +23,8 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
@@ -63,71 +65,74 @@ export const ClientBoard = () => {
.join(" ");
const content = (
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<BoardSelectionProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionProvider>
);
if (representativeWidth === null) return content;
@@ -0,0 +1,114 @@
/**
* Procedural UI sounds for Homarr using the Web Audio API.
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
*/
let audioContext: AudioContext | null = null;
const getAudioContext = (): AudioContext | null => {
if (typeof window === "undefined") return null;
if (!audioContext) {
const AudioContextClass =
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (AudioContextClass) {
audioContext = new AudioContextClass();
}
}
if (audioContext && audioContext.state === "suspended") {
void audioContext.resume();
}
return audioContext;
};
/**
* Creates a white noise audio buffer for percussive textures
*/
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
};
/**
* Procedural trash / delete crunch sound.
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
*/
export const playTrashSound = (volume = 0.35) => {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
// Master Gain
const masterGain = ctx.createGain();
masterGain.gain.setValueAtTime(volume, now);
masterGain.connect(ctx.destination);
// 1. Downward sweep sub-oscillator (physical thud)
const osc = ctx.createOscillator();
const oscGain = ctx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(260, now);
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
oscGain.gain.setValueAtTime(0.7, now);
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.connect(oscGain);
oscGain.connect(masterGain);
osc.start(now);
osc.stop(now + 0.2);
// 2. High-crunch noise burst (crush / crumple texture)
try {
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
const noiseSource = ctx.createBufferSource();
noiseSource.buffer = noiseBuffer;
const noiseFilter = ctx.createBiquadFilter();
noiseFilter.type = "bandpass";
noiseFilter.frequency.setValueAtTime(1400, now);
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
noiseFilter.Q.setValueAtTime(2.2, now);
const noiseGain = ctx.createGain();
noiseGain.gain.setValueAtTime(0.9, now);
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
noiseSource.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseSource.start(now);
noiseSource.stop(now + 0.22);
} catch {
// Ignore if audio buffer creation is constrained
}
};
/**
* Procedural select / pop sound.
*/
export const playPopSound = (volume = 0.25) => {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
osc.frequency.setValueAtTime(460, now);
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
gain.gain.setValueAtTime(volume, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.07);
};
@@ -25,6 +25,7 @@ import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useBoardSelection } from "../selection/board-selection-context";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -59,6 +60,18 @@ type BoardItemCardProps = CardProps & {
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const { isSelected, toggleSelectItem, selectedItemIds } = useBoardSelection();
const selected = isSelected(item.id);
const handleClickCapture = (event: React.MouseEvent) => {
if (!isEditMode) return;
if (event.metaKey || event.ctrlKey || (selectedItemIds.size > 0 && !event.shiftKey)) {
event.preventDefault();
event.stopPropagation();
toggleSelectItem(item.id);
}
};
return (
<Card
@@ -67,6 +80,8 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
w="100%"
h="100%"
data-grid-item-content
data-board-item-selected={selected ? "true" : undefined}
onClickCapture={handleClickCapture}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
@@ -78,12 +93,22 @@ const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCard
root: {
"--opacity": board.opacity / 100,
containerType: "size",
outline: selected ? "3px solid var(--mantine-primary-color-filled)" : undefined,
outlineOffset: selected ? "2px" : undefined,
transition: "outline 150ms ease, box-shadow 150ms ease",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
{selected && (
<Box pos="absolute" top={6} left={6} style={{ zIndex: 10, pointerEvents: "none" }}>
<Badge size="xs" variant="filled" color="primaryColor" radius="xl">
✓
</Badge>
</Box>
)}
{children}
</Card>
);
@@ -0,0 +1,167 @@
"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 { useUpdateBoard } from "@homarr/boards/updater";
import { showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { playPopSound, playTrashSound } from "../audio/board-sounds";
interface BoardSelectionContextValue {
selectedItemIds: Set<string>;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => 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 { updateBoard } = useUpdateBoard();
const t = useI18n();
// 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 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();
updateBoard((previous) => ({
...previous,
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
}));
const notificationId = `batch-remove-${Date.now()}`;
showSuccessNotification({
id: notificationId,
title: t("common.success"),
message: (
<Group justify="space-between" wrap="nowrap">
<Text size="sm">{count === 1 ? "1 item removed" : `${count} items removed`}</Text>
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
updateBoard((previous) => ({
...previous,
items: [...previous.items, ...removedItems],
}));
notifications.hide(notificationId);
}}
>
{t("common.action.undo")}
</Button>
</Group>
),
autoClose: 10_000,
});
}, [board.items, clearSelection, selectedItemIds, t, updateBoard]);
const moveSelectedItemsToSection = useCallback(
(targetSectionId: string) => {
if (selectedItemIds.size === 0) return;
const idsToMove = Array.from(selectedItemIds);
updateBoard((previous) => ({
...previous,
items: previous.items.map((item) =>
!idsToMove.includes(item.id)
? item
: {
...item,
layouts: item.layouts.map((layout) =>
layout.layoutId !== currentLayoutId
? layout
: {
...layout,
sectionId: targetSectionId,
},
),
},
),
}));
clearSelection();
},
[clearSelection, currentLayoutId, selectedItemIds, updateBoard],
);
const value = useMemo(
() => ({
selectedItemIds,
toggleSelectItem,
clearSelection,
isSelected,
removeSelectedItems,
moveSelectedItemsToSection,
}),
[clearSelection, isSelected, moveSelectedItemsToSection, 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;
};
@@ -0,0 +1,114 @@
"use client";
import { ActionIcon, Badge, Button, Group, Menu, Paper, Text, Tooltip } from "@mantine/core";
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useBoardSelection } from "./board-selection-context";
export const BoardSelectionToolbar = () => {
const [isEditMode] = useEditMode();
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
const t = useI18n();
if (!isEditMode || selectedItemIds.size === 0) return null;
const count = selectedItemIds.size;
const mainCanvasSection = board.sections
.filter(
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
)
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
const placementOptions = [
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
...board.sections.flatMap((section) => {
if (section.kind !== "empty") return [];
const lane = getRootSectionLane(section.xOffset);
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
return [
{
value: section.id,
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
},
];
}),
...board.sections
.filter((section) => section.kind === "container")
.map((section) => ({
value: section.id,
label: section.options.title.trim() || t("section.container.untitled"),
})),
];
return (
<Paper
withBorder
shadow="xl"
radius="xl"
p="xs"
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
style={{
position: "fixed",
bottom: 28,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.24)",
backdropFilter: "blur(12px)",
border: "1px solid var(--mantine-color-default-border)",
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
<Group gap="sm" wrap="nowrap">
<Badge size="lg" radius="xl" variant="filled" color="primaryColor" leftSection={<IconCheck size={14} />}>
{count === 1 ? "1 item" : `${count} items`}
</Badge>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
Move to section
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Choose destination section</Menu.Label>
{placementOptions.map((opt) => (
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
{opt.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<Button
size="xs"
variant="light"
color="red"
leftSection={<IconTrash size={15} />}
onClick={removeSelectedItems}
>
Remove
</Button>
<Tooltip label="Deselect (Escape)" withArrow>
<ActionIcon size="sm" variant="subtle" color="gray" onClick={clearSelection} aria-label="Clear selection">
<IconX size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Paper>
);
};