dc992c9bc6
- WIDGET_CONSTRAINTS: Minima aus dem Innenaufbau gerechnet (clock 2/2, search 6/2, calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3), Vorgaben unveraendert; Test A pinnt alle 32 Werte per toEqual - dashboard-grid.tsx: effectiveLayouts (useMemo) ueberschreibt minW/minH jedes gespeicherten Eintrags in jedem Breakpoint aus den Konstanten und hebt zu kleine w/h auf das Minimum an — RGL 2.2.3 nimmt gespeicherte Eintraege woertlich (synchronizeLayoutWithChildren) und liest data-grid nur ohne Eintrag - dashboard-grid.test.tsx: Test 5 angepasst, Test 9 (Ueberschreibung je Breakpoint, unbekannter Typ unveraendert) und Test 9b (h 8 -> 9, w >= minW bleibt) - stopwatch-widget.tsx: Bedienleiste px-2 py-1 text-xs, Zeile gap-1 py-1, damit Stop + Runde + Reset in 4 Spalten passen und die Kachel auf 4x3 schrumpfen kann Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Responsive, noCompactor } from 'react-grid-layout';
|
|
import type { ResponsiveLayouts } from 'react-grid-layout';
|
|
import 'react-grid-layout/css/styles.css';
|
|
import 'react-resizable/css/styles.css';
|
|
import { useTranslations } from 'next-intl';
|
|
import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry';
|
|
import { WidgetWrapper } from './widgets/widget-wrapper';
|
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
|
|
|
// 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: 2` markiert. BREAKPOINTS unveraendert.
|
|
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
|
const COLS = { lg: 24, md: 20, sm: 12, xs: 8, xxs: 2 };
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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 9), 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,
|
|
}: DashboardGridProps) {
|
|
const t = useTranslations('widgets');
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [width, setWidth] = useState(1200);
|
|
|
|
// Measure container width (v2 requires explicit width — Pitfall 1)
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
setWidth(entries[0].contentRect.width);
|
|
});
|
|
observer.observe(containerRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// 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]);
|
|
|
|
// Empty state (D-02)
|
|
if (widgets.length === 0) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
|
|
{/* Bildmarke statt des frueheren handgezeichneten Vier-Quadrate-Symbols.
|
|
Ohne Rahmen und ohne Kontur: die Marke steht hier frei auf dem
|
|
Seitenhintergrund, nicht auf einer eigenen Flaeche. */}
|
|
<TesseraLogo variant="mark" size={64} plateOutline={false} />
|
|
<h2 className="text-lg font-semibold text-muted-foreground">
|
|
{t('emptyHeading')}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('emptyBody')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef}>
|
|
<Responsive
|
|
width={width}
|
|
breakpoints={BREAKPOINTS}
|
|
cols={COLS}
|
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
|
rowHeight={20}
|
|
margin={[8, 8] as [number, number]}
|
|
// 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',
|
|
}}
|
|
resizeConfig={{
|
|
enabled: isEditMode,
|
|
}}
|
|
compactor={noCompactor}
|
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
|
>
|
|
{widgets.map((widget) => {
|
|
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 ?? 4,
|
|
h: constraints?.defaultH ?? 4,
|
|
}),
|
|
minW: constraints?.minW ?? 4,
|
|
minH: constraints?.minH ?? 4,
|
|
}}
|
|
>
|
|
<WidgetWrapper
|
|
widget={widget}
|
|
isEditMode={isEditMode}
|
|
onRemove={onRemoveWidget}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</Responsive>
|
|
</div>
|
|
);
|
|
}
|