From c94dc7a2e8ca63ae92bedaf029416503ebddb1dd Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 21 Aug 2026 01:01:09 +0200 Subject: [PATCH] feat(board): add Cmd/Ctrl multi-item selection, floating action bar, and procedural delete audio synthesis --- .../app/[locale]/boards/(content)/_client.tsx | 127 ++++++------- .../components/board/audio/board-sounds.ts | 114 ++++++++++++ .../components/board/items/item-content.tsx | 25 +++ .../selection/board-selection-context.tsx | 167 ++++++++++++++++++ .../selection/board-selection-toolbar.tsx | 114 ++++++++++++ 5 files changed, 486 insertions(+), 61 deletions(-) create mode 100644 apps/nextjs/src/components/board/audio/board-sounds.ts create mode 100644 apps/nextjs/src/components/board/selection/board-selection-context.tsx create mode 100644 apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index bfd8d33ec..9fa4b7c5d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -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 = ( - - - - - - - - - - -
- {leftColumnCount > 0 && leftSection && ( - - )} + + + + + + + + + + + +
+ {leftColumnCount > 0 && leftSection && ( + + )} -
- -
- - {rightColumnCount > 0 && rightSection && ( - - )} -
-
-
-
-
-
-
+ + + {rightColumnCount > 0 && rightSection && ( + + )} +
+
+
+
+
+ +
+
+ ); if (representativeWidth === null) return content; diff --git a/apps/nextjs/src/components/board/audio/board-sounds.ts b/apps/nextjs/src/components/board/audio/board-sounds.ts new file mode 100644 index 000000000..895b2a0ef --- /dev/null +++ b/apps/nextjs/src/components/board/audio/board-sounds.ts @@ -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); +}; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 15d64af7a..0a28730b0 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -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 ( 0), "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, }, }} p={0} > + {selected && ( + + + ✓ + + + )} {children} ); diff --git a/apps/nextjs/src/components/board/selection/board-selection-context.tsx b/apps/nextjs/src/components/board/selection/board-selection-context.tsx new file mode 100644 index 000000000..be78c668d --- /dev/null +++ b/apps/nextjs/src/components/board/selection/board-selection-context.tsx @@ -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; + toggleSelectItem: (id: string) => void; + clearSelection: () => void; + isSelected: (id: string) => boolean; + removeSelectedItems: () => void; + moveSelectedItemsToSection: (sectionId: string) => void; +} + +const BoardSelectionContext = createContext(null); + +export const BoardSelectionProvider = ({ children }: PropsWithChildren) => { + const [selectedItemIds, setSelectedItemIds] = useState>(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: ( + + {count === 1 ? "1 item removed" : `${count} items removed`} + + + ), + 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 {children}; +}; + +export const useBoardSelection = () => { + const context = useContext(BoardSelectionContext); + if (!context) { + throw new Error("useBoardSelection must be used within a BoardSelectionProvider"); + } + return context; +}; diff --git a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx new file mode 100644 index 000000000..18f7b85e8 --- /dev/null +++ b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx @@ -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 ( + + + }> + {count === 1 ? "1 item" : `${count} items`} + + + {placementOptions.length > 1 && ( + + + + + + Choose destination section + {placementOptions.map((opt) => ( + moveSelectedItemsToSection(opt.value)}> + {opt.label} + + ))} + + + )} + + + + + + + + + + + ); +};