'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Responsive, noCompactor } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; import { useTranslations } from 'next-intl'; import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry'; import { WidgetIcon } from './widgets/widget-icon'; import { WidgetWrapper } from './widgets/widget-wrapper'; // quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten, // 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen // in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig // umgerechnet und mit `__gridVersion` markiert. BREAKPOINTS unveraendert. // // quick-260929-dmx (Version 3): Raster in der BREITE noch einmal doppelt so // fein (48 statt 24 Spalten am lg-Breakpoint, uebrige Breakpoints ebenso // verdoppelt). Zeilenhoehe (20 px) und Abstand (12 px) bleiben. Alle // Breitenwerte (x, w, minW, maxW, WIDGET_CONSTRAINTS, Rueckfallwerte) sind // damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration // einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position. const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }; const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }; // quick-260916-dyv: Ziehen zuverlaessig. // - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx). // - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links, // [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag` // (Favoriten-Widget). react-draggable 4.7.0 prueft // `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element // (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch // INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran // (chunk-WGL5FSZH.mjs:526), der Groessen-Griff funktioniert weiter. // - `threshold: 3` = RGL-Standard (Klick vs. Ziehen). export const WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle'; export const WIDGET_DRAG_CANCEL_SELECTOR = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag'; // quick-260916-dyv: Kein Ueberlappen beim Ablegen, freie Platzierung bleibt. // `noCompactor` (Commit c8f3361, "prevent auto-compaction on drag") ist gewollt: // Luecken werden nicht geschlossen. OHNE `preventCollision` springt beim Ziehen // auf ein belegtes Feld das gezogene Widget auf die Zeile des getroffenen und // das getroffene rutscht um seine EIGENE Hoehe nach unten — ohne Kaskade und // ohne Aufloesung, Ueberlappungen bleiben, weil `noCompactor.compact` die // Identitaet ist (gemessen chunk-76RTO6EO.mjs:279-328); beim Vergroessern in // einen Nachbarn entsteht die Ueberlappung stumm (chunk-WGL5FSZH.mjs:872-885). // MIT `preventCollision: true` bleibt das gezogene Widget am Ausgangsort // (`l.x = oldX; l.y = oldY`), und Vergroessern stoppt am Nachbarn. // `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666). export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true }; /** * Groesse aendern trotz Nachbarn (Design „Mosaik“, Runde 3). * * Mit `preventCollision` verwirft react-grid-layout 2.2.3 beim Ziehen am * Groessengriff die GANZE neue Groesse, sobald sie irgendeinen Nachbarn * beruehrt (chunk-WGL5FSZH.mjs, onResize: w UND h zurueck auf alt). Wer eine * Kachel nur schmaler ziehen will und dabei die Maus eine Rasterzeile nach * unten wackelt, stoesst an die Kachel darunter — und die Breite bleibt * stehen. Genau so liess sich die Favoriten-Kachel oft nicht schmaler machen. * * Diese Regel laeuft vor der Kollisionspruefung: kollidiert die gewuenschte * Groesse, wird zuerst nur die Breite (alte Hoehe), dann nur die Hoehe (alte * Breite) uebernommen — was frei ist, gewinnt. Nur fuer Griffe, die x/y nicht * verschieben (se, e, s); unser Raster zeigt nur den se-Griff. */ function overlaps( a: { x: number; y: number; w: number; h: number }, b: { x: number; y: number; w: number; h: number }, ): boolean { return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; } export const RESIZE_AXIS_FALLBACK: LayoutConstraint = { name: 'resizeAxisFallback', constrainSize(item, w, h, handle, context) { if (handle !== 'se' && handle !== 'e' && handle !== 's') return { w, h }; const current = context.layout.find((l) => l.i === item.i) ?? item; const others = context.layout.filter((l) => l.i !== item.i); const free = (cw: number, ch: number) => !others.some((o) => overlaps({ x: current.x, y: current.y, w: cw, h: ch }, o)); if (free(w, h)) return { w, h }; if (free(w, current.h)) return { w, h: current.h }; if (free(current.w, h)) return { w: current.w, h }; return { w, h }; }, }; const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS_FALLBACK]; const GRID_MARGIN = 12; interface LayoutItemShape { i: string; x: number; y: number; w: number; h: number; minW?: number; minH?: number; } interface DashboardGridProps { layouts: Record>; widgets: Array<{ id: string; widgetType: string; config: Record }>; isEditMode: boolean; onLayoutChange: (allLayouts: ResponsiveLayouts) => void; onRemoveWidget: (id: string) => void; /** Leerer Zustand: oeffnet den Widget-Katalog. */ onOpenCatalog?: () => void; /** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */ onQuickAdd?: (type: WidgetType) => void; } /** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note']; /** * quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben. * * Warum: react-grid-layout 2.2.3 nimmt in `synchronizeLayoutWithChildren` * (chunk-WGL5FSZH.mjs:559-562) fuer jedes Kind MIT Eintrag im `layout` den * Eintrag per `cloneLayoutItem` woertlich — inklusive minW/minH — und liest * `data-grid` nur fuer Kinder OHNE Eintrag. Gespeicherte Anordnungen tragen * minW/minH (RGL liefert sie in `onLayoutChange`, 260916-bwo hat sie verdoppelt * persistiert). Eine Aenderung von WIDGET_CONSTRAINTS allein aendert fuer * bestehende Widgets also NICHTS. Deshalb: in JEDEM Breakpoint minW/minH jedes * Eintrags eines bekannten Typs aus der Tabelle setzen; die Konstanten sind * die einzige Quelle (T-DYV-01: manipulierte Minima wirken nicht). * * Liegt die gespeicherte Groesse UNTER dem neuen Minimum (z. B. Rechner mit * h 8, neues minH 10), wird w/h auf das Minimum angehoben: RGL selbst klemmt * w/h nur beim Vergroessern/Verkleinern (`minMaxSize.constrainSize`, * chunk-KDANGDDL.mjs:26-31; `minConstraints` an `Resizable`, * chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich — * der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten. * Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert. */ function applyConstraintMinima( layouts: Record>, widgets: Array<{ id: string; widgetType: string }>, ): Record> { const typeById = new Map(); for (const w of widgets) typeById.set(w.id, w.widgetType); const result: Record> = {}; for (const key of Object.keys(layouts)) { const entries = layouts[key]; if (!Array.isArray(entries)) continue; result[key] = entries.map((entry) => { const type = typeById.get(entry.i); const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined; if (!constraints) return { ...entry }; return { ...entry, w: Math.max(entry.w, constraints.minW), h: Math.max(entry.h, constraints.minH), minW: constraints.minW, minH: constraints.minH, }; }); } return result; } /** * Dashboard grid wrapper using react-grid-layout v2 Responsive component. * Renders widgets in a draggable/resizable grid when in edit mode. * Shows empty state when no widgets exist. * * v2 API: uses dragConfig/resizeConfig instead of isDraggable/isResizable. */ export function DashboardGrid({ layouts, widgets, isEditMode, onLayoutChange, onRemoveWidget, onOpenCatalog, onQuickAdd, }: DashboardGridProps) { const t = useTranslations('widgets'); const [width, setWidth] = useState(1200); // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf), // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste. // // Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene //
gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der // alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim // Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht, // wenn spaeter die erste Kachel erscheint und das
tatsaechlich // entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert // 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als // (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt // 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts. // // Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen // hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort // ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein // zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200 // lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau // diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute // schon in Produktion und er ist nachweislich richtig (459 px in 1176 px // gemessen) — ein anderer Startwert wuerde eine bisher unerprobte // Breakpoint-Folge einfuehren, ohne etwas zu verbessern. // // Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der // ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0 // und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig // zusammengefallene Flaeche das Raster nicht auf Null setzt und keine // Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst. const nodeRef = useRef(null); const observerRef = useRef(null); const applyWidth = useCallback((next: number) => { if (Number.isFinite(next) && next > 0) { setWidth(next); } }, []); const measureRef = useCallback( (node: HTMLDivElement | null) => { // Ein eventuell laufender Beobachter zuerst trennen — auch der // null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der // Aufraeumpfad (T-VDK-02). observerRef.current?.disconnect(); observerRef.current = null; nodeRef.current = node; if (!node) return; // Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen. applyWidth(node.getBoundingClientRect().width); const observer = new ResizeObserver((entries) => { applyWidth(entries[0].contentRect.width); }); observer.observe(node); observerRef.current = observer; // Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den // Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf. }, [applyWidth], ); useEffect(() => { const onResize = () => { if (nodeRef.current) { applyWidth(nodeRef.current.getBoundingClientRect().width); } }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [applyWidth]); // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // stabil bleibt. const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]); // Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne // Illustration — Titel, ein Satz, drei Vorschlaege zum direkten // Hinzufuegen und der Hauptknopf zum Katalog. if (widgets.length === 0) { return (

{t('emptyState.title')}

{t('emptyState.body')}

{onQuickAdd && (
    {QUICK_ADD_TYPES.map((type) => (
  • ))}
)} {onOpenCatalog && ( )}
); } return (
onLayoutChange(allLayouts)} > {widgets.map((widget, index) => { const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; return (
l.i === widget.id) ?? { x: 0, y: 0, w: constraints?.defaultW ?? 8, h: constraints?.defaultH ?? 4, }), minW: constraints?.minW ?? 8, minH: constraints?.minH ?? 4, }} >
); })}
); }