migrate react-grid-layout v1 to v2

- Replace WidthProvider(Responsive) HOC with useContainerWidth hook
- Update types: Layout (single item) → LayoutItem, Layout[] → Layout
- Replace isDraggable/isResizable/resizeHandles with dragConfig/resizeConfig
- Update EventCallback signature for v2 API
- Remove @types/react-grid-layout (v2 includes its own types)
This commit is contained in:
Josh Hawkins 2026-03-04 19:36:59 -06:00
parent 638ee3bd0a
commit 2e66902fe4
3 changed files with 215 additions and 199 deletions

51
web/package-lock.json generated
View File

@ -63,7 +63,7 @@
"react-device-detect": "^2.2.3",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.8",
"react-grid-layout": "^1.5.0",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0",
"react-icons": "^5.5.0",
@ -96,7 +96,6 @@
"@types/node": "^20.14.10",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",
@ -5223,15 +5222,6 @@
"@types/react": "*"
}
},
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-icons": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
@ -10959,11 +10949,12 @@
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@ -10971,14 +10962,6 @@
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-draggable/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"engines": {
"node": ">=6"
}
},
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
@ -10997,15 +10980,15 @@
}
},
"node_modules/react-grid-layout": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.0.tgz",
"integrity": "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.2.tgz",
"integrity": "sha512-yNo9pxQWoxHWRAwHGSVT4DEGELYPyQ7+q9lFclb5jcqeFzva63/2F72CryS/jiTIr/SBIlTaDdyjqH+ODg8oBw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.5",
"react-draggable": "^4.4.6",
"react-resizable": "^3.0.5",
"resize-observer-polyfill": "^1.5.1"
},
@ -11188,15 +11171,17 @@
}
},
"node_modules/react-resizable": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
"integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.0.3"
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3"
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/react-router": {

View File

@ -69,7 +69,7 @@
"react-device-detect": "^2.2.3",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.8",
"react-grid-layout": "^1.5.0",
"react-grid-layout": "^2.2.2",
"react-hook-form": "^7.52.1",
"react-i18next": "^15.2.0",
"react-icons": "^5.5.0",
@ -102,7 +102,6 @@
"@types/node": "^20.14.10",
"@types/react": "^18.3.2",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",
"@types/react-icons": "^3.0.0",
"@types/react-transition-group": "^4.4.10",
"@types/strftime": "^0.9.8",

View File

@ -14,10 +14,10 @@ import React, {
useState,
} from "react";
import {
ItemCallback,
Layout,
LayoutItem,
Responsive,
WidthProvider,
useContainerWidth,
} from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
@ -116,11 +116,8 @@ export default function DraggableGridLayout({
// grid layout
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
const [gridLayout, setGridLayout, isGridLayoutLoaded] = useUserPersistence<
Layout[]
>(`${cameraGroup}-draggable-layout`);
const [gridLayout, setGridLayout, isGridLayoutLoaded] =
useUserPersistence<Layout>(`${cameraGroup}-draggable-layout`);
const [group] = useUserPersistedOverlayState(
"cameraGroup",
@ -158,11 +155,11 @@ export default function DraggableGridLayout({
const [currentIncludeBirdseye, setCurrentIncludeBirdseye] =
useState<boolean>();
const [currentGridLayout, setCurrentGridLayout] = useState<
Layout[] | undefined
Layout | undefined
>();
const handleLayoutChange = useCallback(
(currentLayout: Layout[]) => {
(currentLayout: Layout) => {
if (!isGridLayoutLoaded || !isEqual(gridLayout, currentGridLayout)) {
return;
}
@ -174,7 +171,7 @@ export default function DraggableGridLayout({
);
const generateLayout = useCallback(
(baseLayout: Layout[] | undefined) => {
(baseLayout: Layout | undefined) => {
if (!isGridLayoutLoaded) {
return;
}
@ -184,7 +181,7 @@ export default function DraggableGridLayout({
? ["birdseye", ...cameras.map((camera) => camera?.name || "")]
: cameras.map((camera) => camera?.name || "");
const optionsMap: Layout[] = baseLayout
const optionsMap: LayoutItem[] = baseLayout
? baseLayout.filter((layout) => cameraNames?.includes(layout.i))
: [];
@ -325,6 +322,24 @@ export default function DraggableGridLayout({
const gridContainerRef = useRef<HTMLDivElement>(null);
const {
width: gridWidth,
containerRef: gridWidthRef,
mounted: gridMounted,
} = useContainerWidth();
// Combine gridContainerRef and gridWidthRef into a single callback ref
const combinedGridRef = useCallback(
(node: HTMLDivElement | null) => {
(
gridContainerRef as React.MutableRefObject<HTMLDivElement | null>
).current = node;
(gridWidthRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;
},
[gridWidthRef],
);
const [{ width: containerWidth, height: containerHeight }] =
useResizeObserver(gridContainerRef);
@ -363,12 +378,14 @@ export default function DraggableGridLayout({
);
}, [availableWidth, marginValue]);
const handleResize: ItemCallback = (
_: Layout[],
oldLayoutItem: Layout,
layoutItem: Layout,
placeholder: Layout,
const handleResize = (
_layout: Layout,
oldLayoutItem: LayoutItem | null,
layoutItem: LayoutItem | null,
placeholder: LayoutItem | null,
) => {
if (!oldLayoutItem || !layoutItem || !placeholder) return;
const heightDiff = layoutItem.h - oldLayoutItem.h;
const widthDiff = layoutItem.w - oldLayoutItem.w;
const changeCoef = oldLayoutItem.w / oldLayoutItem.h;
@ -529,7 +546,7 @@ export default function DraggableGridLayout({
) : (
<div
className="no-scrollbar my-2 select-none overflow-x-hidden px-2 pb-8"
ref={gridContainerRef}
ref={combinedGridRef}
>
<EditGroupDialog
open={editGroup}
@ -537,155 +554,170 @@ export default function DraggableGridLayout({
currentGroups={groups}
activeGroup={group}
/>
<ResponsiveGridLayout
className="grid-layout"
layouts={{
lg: currentGridLayout,
md: currentGridLayout,
sm: currentGridLayout,
xs: currentGridLayout,
xxs: currentGridLayout,
}}
rowHeight={cellHeight}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
margin={[marginValue, marginValue]}
containerPadding={[0, isEditMode ? 6 : 3]}
resizeHandles={isEditMode ? ["sw", "nw", "se", "ne"] : []}
onDragStop={handleLayoutChange}
onResize={handleResize}
onResizeStart={() => setShowCircles(false)}
onResizeStop={handleLayoutChange}
isDraggable={isEditMode}
isResizable={isEditMode}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayerGridItem
key="birdseye"
className={cn(
isEditMode &&
showCircles &&
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
>
{isEditMode && showCircles && <CornerCircles />}
</BirdseyeLivePlayerGridItem>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide w-full`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall h-full`;
} else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {};
const firstStreamEntry = Object.values(availableStreams)[0] || "";
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(
streamNameFromSettings,
);
const streamName = streamExists
? streamNameFromSettings
: firstStreamEntry;
const streamType =
currentGroupStreamingSettings?.[camera.name]?.streamType;
const autoLive =
streamType !== undefined
? streamType !== "no-streaming"
: undefined;
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<GridLiveContextMenu
className={grow}
key={camera.name}
camera={camera.name}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
config={config}
streamMetadata={streamMetadata}
{gridMounted && (
<Responsive
className="grid-layout"
width={gridWidth}
layouts={{
lg: currentGridLayout,
md: currentGridLayout,
sm: currentGridLayout,
xs: currentGridLayout,
xxs: currentGridLayout,
}}
rowHeight={cellHeight}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }}
margin={[marginValue, marginValue]}
containerPadding={[0, isEditMode ? 6 : 3]}
resizeConfig={{
enabled: isEditMode,
handles: isEditMode ? ["sw", "nw", "se", "ne"] : [],
}}
dragConfig={{
enabled: isEditMode,
}}
onDragStop={handleLayoutChange}
onResize={handleResize}
onResizeStart={() => setShowCircles(false)}
onResizeStop={handleLayoutChange}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<BirdseyeLivePlayerGridItem
key="birdseye"
className={cn(
isEditMode &&
showCircles &&
"outline outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
>
<LivePlayer
key={camera.name}
streamName={streamName}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={showStillWithoutActivity ?? true}
alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL}
cameraRef={cameraRef}
className={cn(
"rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode &&
showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
playInBackground={false}
showStats={statsStates[camera.name]}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
/>
{isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu>
);
})}
</ResponsiveGridLayout>
</BirdseyeLivePlayerGridItem>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
if (aspectRatio > ASPECT_WIDE_LAYOUT) {
grow = `aspect-wide w-full`;
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
grow = `aspect-tall h-full`;
} else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {};
const firstStreamEntry =
Object.values(availableStreams)[0] || "";
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName ||
"";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(
streamNameFromSettings,
);
const streamName = streamExists
? streamNameFromSettings
: firstStreamEntry;
const streamType =
currentGroupStreamingSettings?.[camera.name]?.streamType;
const autoLive =
streamType !== undefined
? streamType !== "no-streaming"
: undefined;
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<GridLiveContextMenu
className={grow}
key={camera.name}
camera={camera.name}
streamName={streamName}
cameraGroup={cameraGroup}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name]}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
config={config}
streamMetadata={streamMetadata}
>
<LivePlayer
key={camera.name}
streamName={streamName}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={
showStillWithoutActivity ?? true
}
alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL}
cameraRef={cameraRef}
className={cn(
"rounded-lg bg-black md:rounded-2xl",
grow,
isEditMode &&
showCircles &&
"outline-2 outline-muted-foreground hover:cursor-grab hover:outline-4 active:cursor-grabbing",
)}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
playInBackground={false}
showStats={statsStates[camera.name]}
onClick={() => {
!isEditMode && onSelectCamera(camera.name);
}}
onError={(e) => {
setPreferredLiveModes((prevModes) => {
const newModes = { ...prevModes };
if (e === "mse-decode") {
newModes[camera.name] = "webrtc";
} else {
newModes[camera.name] = "jsmpeg";
}
return newModes;
});
}}
onResetLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name]}
volume={volumeStates[camera.name]}
/>
{isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu>
);
})}
</Responsive>
)}
{isDesktop && (
<div
className={cn(