feat(web): Design Mosaik Runde 3 - Reiter in der Seitenleiste, lebendigere Kacheln
- Dashboard-Reiter stehen in der aufgeklappten Seitenleiste als Liste unter "Dashboard" (Ziehen sortiert senkrecht, Umbenennen/Loeschen/Neu im Bearbeitungsmodus); eingeklappt und auf dem Handy wie bisher in der Kopfzeile. Die Kopfzeile zeigt sonst den Namen des Dashboards. - Begruessung einzeilig auf Hoehe der Befehlsleiste. - Kachel-Groesse: kollidiert die gezogene Groesse mit einem Nachbarn, wird nur die freie Achse uebernommen - vorher verwarf react-grid-layout mit preventCollision die ganze Aenderung, sobald die Maus eine Zeile nach unten wackelte (Favoriten liessen sich deshalb oft nicht schmaler ziehen). - Ansicht: belegte Spalten mittig, Begruessung/Befehlsleiste ruecken mit. - Kalender: einzeilige Terminliste mit "Heute"/"Morgen" in Akzentfarbe, Ort im Tooltip, Standardhoehe 16 statt 12. - Hintergrund "Bluete" im Dunkelmodus weggelassen (Farbstufen), Ersatz Nebel. - Kacheln: Symbol-Chip in der Akzentfarbe, leichtes Anheben beim Zeigen, gestaffeltes Einblenden beim Laden (ohne bei reduzierter Bewegung). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
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';
|
||||
@@ -44,6 +45,74 @@ export const WIDGET_DRAG_CANCEL_SELECTOR =
|
||||
// `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;
|
||||
|
||||
/**
|
||||
* Seitlicher Versatz, um die belegten Spalten in der Ansicht mittig zu
|
||||
* stellen (Design „Mosaik“, Runde 3): auf breiten Bildschirmen blieb rechts
|
||||
* ein leerer Streifen, weil die Kacheln links beginnen. Belegt die Anordnung
|
||||
* schon (fast) die ganze Breite, bleibt es bei 0.
|
||||
*/
|
||||
export function centeringOffset(
|
||||
items: Array<{ x: number; w: number }> | undefined,
|
||||
cols: number,
|
||||
width: number,
|
||||
): number {
|
||||
if (!items || items.length === 0 || cols <= 0 || width <= 0) return 0;
|
||||
const usedCols = Math.min(cols, Math.max(...items.map((l) => l.x + l.w)));
|
||||
if (usedCols >= cols) return 0;
|
||||
const colWidth = (width - GRID_MARGIN * (cols + 1)) / cols;
|
||||
const freeWidth = (cols - usedCols) * (colWidth + GRID_MARGIN);
|
||||
return Math.max(0, Math.floor(freeWidth / 2));
|
||||
}
|
||||
|
||||
/** Breakpoint zur Breite — gleiche Regel wie react-grid-layout (strikt groesser). */
|
||||
function breakpointFor(width: number): keyof typeof COLS {
|
||||
const order = (Object.keys(BREAKPOINTS) as Array<keyof typeof BREAKPOINTS>).sort(
|
||||
(a, b) => BREAKPOINTS[b] - BREAKPOINTS[a],
|
||||
);
|
||||
return order.find((bp) => width > BREAKPOINTS[bp]) ?? 'xxs';
|
||||
}
|
||||
|
||||
interface LayoutItemShape {
|
||||
i: string;
|
||||
x: number;
|
||||
@@ -64,6 +133,8 @@ interface DashboardGridProps {
|
||||
onOpenCatalog?: () => void;
|
||||
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
||||
onQuickAdd?: (type: WidgetType) => void;
|
||||
/** Meldet den seitlichen Versatz der zentrierten Ansicht (0 im Bearbeitungsmodus). */
|
||||
onInsetChange?: (px: number) => void;
|
||||
}
|
||||
|
||||
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
||||
@@ -132,6 +203,7 @@ export function DashboardGrid({
|
||||
onRemoveWidget,
|
||||
onOpenCatalog,
|
||||
onQuickAdd,
|
||||
onInsetChange,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const [width, setWidth] = useState(1200);
|
||||
@@ -212,6 +284,17 @@ export function DashboardGrid({
|
||||
// stabil bleibt.
|
||||
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
|
||||
|
||||
// Zentrieren nur in der Ansicht — im Bearbeitungsmodus braucht es das
|
||||
// volle Raster, damit Kacheln auch in die freien Spalten rechts passen.
|
||||
const breakpoint = breakpointFor(width);
|
||||
const inset = isEditMode
|
||||
? 0
|
||||
: centeringOffset(effectiveLayouts[breakpoint] ?? effectiveLayouts.lg, COLS[breakpoint], width);
|
||||
|
||||
useEffect(() => {
|
||||
onInsetChange?.(widgets.length === 0 ? 0 : inset);
|
||||
}, [inset, onInsetChange, widgets.length]);
|
||||
|
||||
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
|
||||
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
||||
// Hinzufuegen und der Hauptknopf zum Katalog.
|
||||
@@ -238,7 +321,7 @@ export function DashboardGrid({
|
||||
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} />
|
||||
<WidgetIcon type={type} variant="plain" />
|
||||
{t(WIDGET_REGISTRY[type].nameKey)}
|
||||
</button>
|
||||
</li>
|
||||
@@ -257,14 +340,22 @@ export function DashboardGrid({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
// overflow-x-clip: die verschobene Rasterflaeche ragt rechts ueber den
|
||||
// Rand; `clip` (nicht `hidden`) laesst die Schatten oben/unten stehen.
|
||||
<div ref={measureRef} className="overflow-x-clip">
|
||||
<div
|
||||
data-testid="dashboard-grid-inset"
|
||||
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
style={inset > 0 ? { transform: `translateX(${inset}px)` } : undefined}
|
||||
>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||
rowHeight={20}
|
||||
margin={[12, 12] as [number, number]}
|
||||
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={{
|
||||
@@ -279,7 +370,7 @@ export function DashboardGrid({
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||
>
|
||||
{widgets.map((widget) => {
|
||||
{widgets.map((widget, index) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||
return (
|
||||
<div
|
||||
@@ -299,11 +390,13 @@ export function DashboardGrid({
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onRemove={onRemoveWidget}
|
||||
enterIndex={index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user