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:
2026-09-28 15:58:01 +02:00
parent b06a2d1066
commit 8532b63097
20 changed files with 552 additions and 73 deletions
@@ -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>
);
}