fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund

- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent 071082983b
commit 12214a948e
21 changed files with 1194 additions and 205 deletions
@@ -1,13 +1,19 @@
'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 { noCompactor, Responsive } 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 {
clampWidthToCols,
GRID_COLS,
WIDGET_CONSTRAINTS,
WIDGET_REGISTRY,
type WidgetType,
} from './widget-registry';
import { WidgetIcon } from './widgets/widget-icon';
import { WidgetWrapper } from './widgets/widget-wrapper';
@@ -23,7 +29,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
// 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 };
// Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
// weil auch der Store sie fuer neue Kacheln braucht.
const COLS = GRID_COLS;
// quick-260916-dyv: Ziehen zuverlaessig.
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
@@ -138,6 +146,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
* 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.
*
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
* verschoben ist.
*/
function applyConstraintMinima(
layouts: Record<string, Array<LayoutItemShape>>,
@@ -150,18 +169,42 @@ function applyConstraintMinima(
for (const key of Object.keys(layouts)) {
const entries = layouts[key];
if (!Array.isArray(entries)) continue;
result[key] = entries.map((entry) => {
const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
const targetW = new Map<string, number>();
const next = entries.map((entry) => {
const type = typeById.get(entry.i);
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
if (!constraints) return { ...entry };
const minW = clampWidthToCols(constraints.minW, key);
const w = Math.min(entry.w, cols);
if (w < minW) targetW.set(entry.i, minW);
return {
...entry,
w: Math.max(entry.w, constraints.minW),
w,
h: Math.max(entry.h, constraints.minH),
minW: constraints.minW,
minW,
minH: constraints.minH,
};
});
for (const item of next) {
const target = targetW.get(item.i);
if (target === undefined) continue;
const others = next.filter((o) => o !== item);
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
// entstehen nicht durch das Anheben.
const blockers = others.filter((o) => !overlaps(item, o));
while (
item.w < target &&
item.x + item.w + 1 <= cols &&
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
) {
item.w += 1;
}
}
result[key] = next;
}
return result;
}
@@ -259,7 +302,10 @@ export function DashboardGrid({
// 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]);
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
@@ -269,7 +315,18 @@ export function DashboardGrid({
<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">
<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" />
@@ -296,7 +353,19 @@ export function DashboardGrid({
)}
{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>
<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>
)}