bc4c0119de
Die Zentrierung der belegten Spalten (Design Mosaik, Runde 3) liess Widgets nach dem Bearbeiten springen, z. B. ein einzelnes Widget oben links in die Seitenmitte (Nutzer, live 29.09.). Auf Wunsch ersatzlos entfernt: Ansicht = Bearbeitungsraster. centeringOffset, breakpointFor und die ungenutzte Prop onInsetChange entfallen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
'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<string, Array<LayoutItemShape>>;
|
|
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
|
|
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<string, Array<LayoutItemShape>>,
|
|
widgets: Array<{ id: string; widgetType: string }>,
|
|
): Record<string, Array<LayoutItemShape>> {
|
|
const typeById = new Map<string, string>();
|
|
for (const w of widgets) typeById.set(w.id, w.widgetType);
|
|
|
|
const result: Record<string, Array<LayoutItemShape>> = {};
|
|
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
|
|
// <div> 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 <div> 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<HTMLDivElement | null>(null);
|
|
const observerRef = useRef<ResizeObserver | null>(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 (
|
|
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
|
|
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
|
|
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
|
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
|
<rect x="14" y="12" width="7" height="9" rx="1.5" />
|
|
<rect x="3" y="16" width="7" height="5" rx="1.5" />
|
|
</svg>
|
|
</span>
|
|
<h2 className="text-base font-semibold text-foreground">{t('emptyState.title')}</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">{t('emptyState.body')}</p>
|
|
{onQuickAdd && (
|
|
<ul className="mt-4 flex flex-wrap justify-center gap-2">
|
|
{QUICK_ADD_TYPES.map((type) => (
|
|
<li key={type}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onQuickAdd(type)}
|
|
className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<WidgetIcon type={type} variant="plain" />
|
|
{t(WIDGET_REGISTRY[type].nameKey)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{onOpenCatalog && (
|
|
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
|
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
|
{t('addWidget')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={measureRef}>
|
|
<Responsive
|
|
width={width}
|
|
breakpoints={BREAKPOINTS}
|
|
cols={COLS}
|
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
|
rowHeight={20}
|
|
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
|
constraints={GRID_CONSTRAINTS}
|
|
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
|
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
|
dragConfig={{
|
|
enabled: isEditMode,
|
|
handle: WIDGET_DRAG_HANDLE_SELECTOR,
|
|
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
|
threshold: 3,
|
|
}}
|
|
resizeConfig={{
|
|
enabled: isEditMode,
|
|
}}
|
|
compactor={FREE_PLACEMENT_COMPACTOR}
|
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
|
>
|
|
{widgets.map((widget, index) => {
|
|
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
|
return (
|
|
<div
|
|
key={widget.id}
|
|
data-grid={{
|
|
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
|
x: 0,
|
|
y: 0,
|
|
w: constraints?.defaultW ?? 8,
|
|
h: constraints?.defaultH ?? 4,
|
|
}),
|
|
minW: constraints?.minW ?? 8,
|
|
minH: constraints?.minH ?? 4,
|
|
}}
|
|
>
|
|
<WidgetWrapper
|
|
widget={widget}
|
|
isEditMode={isEditMode}
|
|
onRemove={onRemoveWidget}
|
|
enterIndex={index}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</Responsive>
|
|
</div>
|
|
);
|
|
}
|