import React, { useRef, useState } from 'react'; import { Box, Button, Stack, TextField, Typography } from '@mui/material'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import GridLayout, { getAllCollisions, noCompactor, verticalCompactor, useContainerWidth, type Compactor, type Layout } from 'react-grid-layout'; import { arrangeDashboardWidgets, type DashboardLayout, type DashboardWidget, WIDGETS } from '../dashboardLayout'; import { useI18n } from '../i18n/I18nProvider'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; const preserveGaps: Compactor = { ...verticalCompactor, compact(layout, cols) { // The library resolves collisions; untouched gaps survive mount and reload. return layout.some(item => getAllCollisions(layout, item).length > 0) ? verticalCompactor.compact(layout, cols) : noCompactor.compact(layout, cols); }, }; type Props = { layout: DashboardLayout; editing: boolean; disabled: boolean; onChange: (layout: DashboardLayout) => void; childrenFor: (widget: DashboardWidget) => React.ReactNode; }; export default function DashboardGrid({ layout, editing, disabled, onChange, childrenFor }: Props) { const { t } = useI18n(); const { width, containerRef } = useContainerWidth({ initialWidth: 0 }); const wide = width >= 800; const [announcement, announce] = useState(''); const focus = useRef(null); const contents = new Map(layout.widgets.filter(widget => widget.visible).map(widget => [widget.id, childrenFor(widget)])); const visible = layout.widgets.filter(widget => widget.visible && contents.get(widget.id) != null); const update = (id: string, patch: Partial) => { if (disabled) return; const widgets = layout.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget); onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) }); }; const move = (id: string, direction: number) => { const index = layout.widgets.findIndex(widget => widget.id === id); const target = index + direction; if (disabled || target < 0 || target >= layout.widgets.length) return; const widgets = [...layout.widgets]; const [moved] = widgets.splice(index, 1); widgets.splice(target, 0, moved); onChange({ ...layout, widgets: arrangeDashboardWidgets(widgets) }); announce(t('dashboardMoved', { name: t(WIDGETS.find(widget => widget.id === id)!.titleKey), position: target + 1, total: widgets.length })); requestAnimationFrame(() => focus.current?.querySelector(`[data-dashboard-widget="${id}"] button[data-move]:not(:disabled)`)?.focus()); }; const finish = (next: Layout) => { if (!editing || disabled) return; // Mount, compaction and viewport changes never write preferences. if (next.some(item => item.x < 0 || item.y < 0 || item.x + item.w > 12 || item.y + item.h > 256)) return; const widgets = layout.widgets.map(widget => { const item = next.find(item => item.i === widget.id); return item ? { ...widget, x: item.x, y: item.y, w: item.w, h: item.h, size: item.w === 12 ? 'full' as const : 'standard' as const } : widget; }); const ordered = widgets.filter(widget => widget.visible).sort((a, b) => a.y - b.y || a.x - b.x); onChange({ ...layout, widgets: widgets.map(widget => widget.visible ? ordered.shift()! : widget) }); announce(t('assetsUnsavedChanges')); }; const tiles = visible.map(widget => { const name = t(WIDGETS.find(item => item.id === widget.id)!.titleKey); const index = layout.widgets.indexOf(widget); return {editing ? {name} {[-1, 1].map(direction => )} update(widget.id, { size: event.target.value as 'standard' | 'full', w: event.target.value === 'full' ? 12 : 6 })}> { const h = Number(event.target.value); if (Number.isInteger(h) && h >= 4 && h <= 24) update(widget.id, { h }); }} /> : null} {contents.get(widget.id)} ; }); return {editing ? {t('dashboardGridHelp')} : null} {announcement} {wide ? ({ i: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h, minW: widget.id === 'cards' ? 12 : 4, maxW: 12, minH: 4, maxH: 24 }))} gridConfig={{ cols: 12, rowHeight: 40, margin: [16, 16], containerPadding: [0, 0], maxRows: 256 }} dragConfig={{ enabled: editing && !disabled, handle: '.dashboard-grid-handle', allowMobileScroll: true }} resizeConfig={{ enabled: editing && !disabled, handles: ['se'] }} onDragStop={finish} onResizeStop={finish}>{tiles} : {tiles}} ; }