feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s

Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im
Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3).
Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch))
eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher.
Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie
(createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch).
Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px
folgt der Maus, 700 px ohne Skalierung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:19:27 +02:00
parent bd73fa5e74
commit af87c2c112
9 changed files with 653 additions and 79 deletions
+1
View File
@@ -10,6 +10,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert ### Geändert
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt. - Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird. - Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts. - Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
+18 -10
View File
@@ -1,28 +1,32 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl'; import {
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot'; BackgroundPicker,
DashboardBackdrop,
useDashboardBackground,
} from '@/components/dashboard/dashboard-background';
import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { registerWidget } from '@/components/dashboard/widget-registry'; import { registerWidget } from '@/components/dashboard/widget-registry';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget'; import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
import { useDashboardStore } from '@/lib/stores/dashboard-store'; import { useDashboardStore } from '@/lib/stores/dashboard-store';
import type { WidgetType } from '@/components/dashboard/widget-registry';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -71,6 +75,7 @@ export default function DashboardPage() {
activeDashboardId, activeDashboardId,
isSwitchingDashboard, isSwitchingDashboard,
layouts, layouts,
canvas,
widgets, widgets,
isEditMode, isEditMode,
isLoading, isLoading,
@@ -85,6 +90,7 @@ export default function DashboardPage() {
renameDashboard, renameDashboard,
deleteDashboard, deleteDashboard,
reorderDashboards, reorderDashboards,
captureCanvas,
} = useDashboardStore(); } = useDashboardStore();
// Load dashboard data on mount // Load dashboard data on mount
@@ -200,6 +206,8 @@ export default function DashboardPage() {
onRemoveWidget={removeWidget} onRemoveWidget={removeWidget}
onOpenCatalog={() => setCatalogOpen(true)} onOpenCatalog={() => setCatalogOpen(true)}
onQuickAdd={(type) => addWidget(type)} onQuickAdd={(type) => addWidget(type)}
canvas={canvas}
onCaptureCanvas={captureCanvas}
/> />
)} )}
@@ -1,8 +1,14 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { Children, isValidElement } from 'react'; import { Children, isValidElement } from 'react';
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { stubResizeObserver } from '@/test/fake-resize-observer'; import { stubResizeObserver } from '@/test/fake-resize-observer';
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid'; import {
computeCanvasScale,
DASHBOARD_BOTTOM_GAP_PX,
RESIZE_AXIS_FALLBACK,
scaledPositionStrategy,
} from './dashboard-grid';
// Mock CSS imports that vitest cannot resolve // Mock CSS imports that vitest cannot resolve
vi.mock('react-grid-layout/css/styles.css', () => ({})); vi.mock('react-grid-layout/css/styles.css', () => ({}));
@@ -652,3 +658,139 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 }); expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
}); });
}); });
describe('Leinwand: Dashboard wie ein Bild mitskalieren', () => {
const ONE = {
layouts: { lg: [{ i: 'inst-1', x: 0, y: 0, w: 8, h: 4 }], md: [], sm: [], xs: [], xxs: [] },
widgets: [{ id: 'inst-1', widgetType: 'clock', config: {} }],
};
// jsdom: innerHeight 768, getBoundingClientRect -> 0 -> verfuegbare Hoehe 748.
const JSDOM_AVAILABLE_H = 768 - DASHBOARD_BOTTOM_GAP_PX;
function setInnerHeight(value: number) {
Object.defineProperty(window, 'innerHeight', { value, configurable: true, writable: true });
}
afterEach(() => setInnerHeight(768));
async function renderGrid(props: {
canvas?: { w: number; h: number } | null;
onCaptureCanvas?: (area: { w: number; h: number }) => void;
}) {
captured.props = null;
const { DashboardGrid } = await import('./dashboard-grid');
const view = render(
<DashboardGrid
{...ONE}
isEditMode={false}
onLayoutChange={vi.fn()}
onRemoveWidget={vi.fn()}
{...props}
/>,
);
const outer = view.container.firstElementChild as HTMLElement;
const inner = outer.firstElementChild as HTMLElement;
return { outer, inner };
}
describe('computeCanvasScale', () => {
it('breitenbegrenzt: freier Platz unten', () => {
expect(computeCanvasScale({ w: 1280, h: 900 }, { w: 1600, h: 800 })).toBe(0.8);
});
it('hoehenbegrenzt: freier Platz rechts', () => {
expect(computeCanvasScale({ w: 1920, h: 600 }, { w: 1600, h: 800 })).toBe(0.75);
});
it('Vergroesserung bei groesserem Bildschirm (s > 1)', () => {
expect(computeCanvasScale({ w: 2400, h: 1300 }, { w: 1600, h: 800 })).toBe(1.5);
});
it('gleiche Flaeche -> 1', () => {
expect(computeCanvasScale({ w: 1600, h: 800 }, { w: 1600, h: 800 })).toBe(1);
});
it('unbrauchbare Hoehe begrenzt nicht, dann zaehlt nur die Breite', () => {
expect(computeCanvasScale({ w: 800, h: 0 }, { w: 1600, h: 800 })).toBe(0.5);
expect(computeCanvasScale({ w: 800, h: Number.NaN }, { w: 1600, h: 800 })).toBe(0.5);
});
});
describe('scaledPositionStrategy (Ziehen/Groesse aendern unter transform)', () => {
it('s = 1 ist die Standardstrategie', () => {
expect(scaledPositionStrategy(1)).toBe(defaultPositionStrategy);
});
it('s != 1: transform-Strategie mit scale, OHNE calcDragPosition (RGL rechnet dann relativ zum Raster)', () => {
const strategy = scaledPositionStrategy(0.5);
expect(strategy.type).toBe('transform');
expect(strategy.scale).toBe(0.5);
expect(strategy.calcDragPosition).toBeUndefined();
expect(strategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 })).toEqual(
defaultPositionStrategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 }),
);
});
it('Grund: createScaledStrategy rechnet den Ziehstart in Fensterkoordinaten (ohne Raster-Versatz)', () => {
// Kachel sichtbar bei clientX 300 (Raster beginnt bei 250 neben der
// Seitenleiste, Kachel also 50 px im Raster), Maus 10 px in der Kachel.
const pos = createScaledStrategy(0.5).calcDragPosition?.(310, 110, 10, 10);
// Richtig waere (300 - 250) / 0.5 = 100; geliefert wird 300 / 0.5 = 600.
expect(pos?.left).toBe(600);
});
});
it('skalierter Modus: virtuelle Breite canvas.w, transform scale(s) links oben, sichtbare Hoehe = Rasterhoehe x s', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const { outer, inner } = await renderGrid({ canvas: { w: 2000, h: 800 } });
// s = min(1000 / 2000, 748 / 800) = 0.5
expect(captured.props?.width).toBe(2000);
expect(inner.style.width).toBe('2000px');
expect(inner.style.transform).toBe('scale(0.5)');
expect(inner.style.transformOrigin).toBe('top left');
expect(outer.style.height).toBe('400px');
expect(outer.style.overflow).toBe('hidden');
const strategy = captured.props?.positionStrategy as { scale: number };
expect(strategy.scale).toBe(0.5);
});
it('skalierter Modus hoehenbegrenzt und vergroessernd', async () => {
stubResizeObserver({ width: 1000, height: 800 });
setInnerHeight(420); // verfuegbar 400
const first = await renderGrid({ canvas: { w: 1000, h: 800 } });
expect(first.inner.style.transform).toBe('scale(0.5)');
cleanup();
setInnerHeight(768);
const second = await renderGrid({ canvas: { w: 800, h: 374 } });
// min(1000 / 800, 748 / 374) = 1.25
expect(second.inner.style.transform).toBe('scale(1.25)');
expect(captured.props?.width).toBe(800);
});
it('schmal (< 768): wie bisher — Containerbreite, kein transform, keine Erfassung', async () => {
stubResizeObserver({ width: 600, height: 800 });
const onCaptureCanvas = vi.fn();
const { outer, inner } = await renderGrid({ canvas: { w: 1600, h: 800 }, onCaptureCanvas });
expect(captured.props?.width).toBe(600);
expect(inner.style.transform).toBe('');
expect(outer.style.height).toBe('');
expect(captured.props?.positionStrategy).toBe(defaultPositionStrategy);
cleanup();
await renderGrid({ canvas: null, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
it('ohne Leinwand am Desktop: unskaliert und die verfuegbare Flaeche wird zum Erfassen gemeldet', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
const { inner } = await renderGrid({ canvas: null, onCaptureCanvas });
expect(captured.props?.width).toBe(1000);
expect(inner.style.transform).toBe('');
expect(onCaptureCanvas).toHaveBeenCalledWith({ w: 1000, h: JSDOM_AVAILABLE_H });
});
it('mit Leinwand wird nicht erneut erfasst', async () => {
stubResizeObserver({ width: 1000, height: 800 });
const onCaptureCanvas = vi.fn();
await renderGrid({ canvas: { w: 1000, h: 748 }, onCaptureCanvas });
expect(onCaptureCanvas).not.toHaveBeenCalled();
});
});
@@ -3,10 +3,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout'; import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
import { noCompactor, Responsive } from 'react-grid-layout'; import { noCompactor, Responsive } from 'react-grid-layout';
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core'; import {
defaultConstraints,
defaultPositionStrategy,
type LayoutConstraint,
type PositionStrategy,
transformStrategy,
} from 'react-grid-layout/core';
import 'react-grid-layout/css/styles.css'; import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css'; import 'react-resizable/css/styles.css';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
import { import {
clampWidthToCols, clampWidthToCols,
GRID_COLS, GRID_COLS,
@@ -101,6 +108,66 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
const GRID_MARGIN = 12; const GRID_MARGIN = 12;
/**
* Dashboard wie ein Bild mitskalieren (Leinwand).
*
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
* Platz frei.
*
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
* das Ergebnis per `transform: scale(s)` mit
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
*
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
* gemessene Containerbreite, kein transform, keine Erfassung.
*/
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
export const DASHBOARD_BOTTOM_GAP_PX = 20;
/**
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
* dann entscheidet nur die Breite.
*/
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
const byWidth = available.w / canvas.w;
const byHeight = available.h / canvas.h;
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
return Number.isFinite(s) && s > 0 ? s : 1;
}
/**
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
*
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
*/
export function scaledPositionStrategy(scale: number): PositionStrategy {
if (scale === 1) return defaultPositionStrategy;
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
}
interface LayoutItemShape { interface LayoutItemShape {
i: string; i: string;
x: number; x: number;
@@ -121,6 +188,10 @@ interface DashboardGridProps {
onOpenCatalog?: () => void; onOpenCatalog?: () => void;
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */ /** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
onQuickAdd?: (type: WidgetType) => void; onQuickAdd?: (type: WidgetType) => void;
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
canvas?: GridCanvas | null;
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
onCaptureCanvas?: (area: GridCanvas) => void;
} }
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */ /** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
@@ -224,9 +295,15 @@ export function DashboardGrid({
onRemoveWidget, onRemoveWidget,
onOpenCatalog, onOpenCatalog,
onQuickAdd, onQuickAdd,
canvas = null,
onCaptureCanvas,
}: DashboardGridProps) { }: DashboardGridProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const [width, setWidth] = useState(1200); const [width, setWidth] = useState(1200);
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
const [availableHeight, setAvailableHeight] = useState(0);
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
const [innerHeight, setInnerHeight] = useState(0);
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf), // quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste. // nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
@@ -265,6 +342,16 @@ export function DashboardGrid({
} }
}, []); }, []);
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
// unten. Das aeussere Element ist selbst nicht transformiert, seine
// Oberkante ist also die echte.
const applyHeight = useCallback((node: HTMLElement) => {
const top = node.getBoundingClientRect().top + window.scrollY;
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
}, []);
const measureRef = useCallback( const measureRef = useCallback(
(node: HTMLDivElement | null) => { (node: HTMLDivElement | null) => {
// Ein eventuell laufender Beobachter zuerst trennen — auch der // Ein eventuell laufender Beobachter zuerst trennen — auch der
@@ -277,27 +364,64 @@ export function DashboardGrid({
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen. // Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
applyWidth(node.getBoundingClientRect().width); applyWidth(node.getBoundingClientRect().width);
applyHeight(node);
const observer = new ResizeObserver((entries) => { const observer = new ResizeObserver((entries) => {
applyWidth(entries[0].contentRect.width); applyWidth(entries[0].contentRect.width);
applyHeight(node);
}); });
observer.observe(node); observer.observe(node);
observerRef.current = observer; observerRef.current = observer;
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den // Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf. // Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
}, },
[applyWidth], [applyWidth, applyHeight],
); );
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => {
if (nodeRef.current) { if (nodeRef.current) {
applyWidth(nodeRef.current.getBoundingClientRect().width); applyWidth(nodeRef.current.getBoundingClientRect().width);
applyHeight(nodeRef.current);
} }
}; };
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, [applyWidth]); }, [applyWidth, applyHeight]);
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
// vom transform unberuehrt — genau das Gewuenschte.
const innerObserverRef = useRef<ResizeObserver | null>(null);
const innerRef = useCallback((node: HTMLDivElement | null) => {
innerObserverRef.current?.disconnect();
innerObserverRef.current = null;
if (!node) return;
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
const observer = new ResizeObserver((entries) => {
const h = entries[0].contentRect.height;
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
});
observer.observe(node);
innerObserverRef.current = observer;
}, []);
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
const isDesktop = width >= CANVAS_MIN_WIDTH;
const scaled = isDesktop && canvas !== null;
const scale = scaled ? computeCanvasScale({ w: width, h: availableHeight }, canvas) : 1;
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
// Effekt tut nichts mehr.
const hasWidgets = widgets.length > 0;
useEffect(() => {
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
onCaptureCanvas({ w: width, h: availableHeight });
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS — // quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge // siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
@@ -374,56 +498,72 @@ export function DashboardGrid({
); );
} }
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
const outerStyle: React.CSSProperties | undefined = scaled
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
: undefined;
const innerStyle: React.CSSProperties | undefined = scaled
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
: undefined;
return ( return (
<div ref={measureRef}> <div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
<Responsive <div ref={innerRef} style={innerStyle}>
width={width} <Responsive
breakpoints={BREAKPOINTS} width={scaled ? canvas.w : width}
cols={COLS} positionStrategy={positionStrategy}
layouts={effectiveLayouts as ResponsiveLayouts} breakpoints={BREAKPOINTS}
rowHeight={20} cols={COLS}
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]} layouts={effectiveLayouts as ResponsiveLayouts}
constraints={GRID_CONSTRAINTS} rowHeight={20}
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin). constraints={GRID_CONSTRAINTS}
dragConfig={{ // containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
enabled: isEditMode, // dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
handle: WIDGET_DRAG_HANDLE_SELECTOR, dragConfig={{
cancel: WIDGET_DRAG_CANCEL_SELECTOR, enabled: isEditMode,
threshold: 3, handle: WIDGET_DRAG_HANDLE_SELECTOR,
}} cancel: WIDGET_DRAG_CANCEL_SELECTOR,
resizeConfig={{ threshold: 3,
enabled: isEditMode, }}
}} resizeConfig={{
compactor={FREE_PLACEMENT_COMPACTOR} enabled: isEditMode,
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)} }}
> compactor={FREE_PLACEMENT_COMPACTOR}
{widgets.map((widget, index) => { onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType]; >
return ( {widgets.map((widget, index) => {
<div const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
key={widget.id} return (
data-grid={{ <div
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? { key={widget.id}
x: 0, data-grid={{
y: 0, ...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
w: constraints?.defaultW ?? 8, x: 0,
h: constraints?.defaultH ?? 4, y: 0,
}), w: constraints?.defaultW ?? 8,
minW: constraints?.minW ?? 8, h: constraints?.defaultH ?? 4,
minH: constraints?.minH ?? 4, }),
}} minW: constraints?.minW ?? 8,
> minH: constraints?.minH ?? 4,
<WidgetWrapper }}
widget={widget} >
isEditMode={isEditMode} <WidgetWrapper
onRemove={onRemoveWidget} widget={widget}
enterIndex={index} isEditMode={isEditMode}
/> onRemove={onRemoveWidget}
</div> enterIndex={index}
); />
})} </div>
</Responsive> );
})}
</Responsive>
</div>
</div> </div>
); );
} }
+80 -4
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
GRID_CANVAS_KEY,
GRID_SCALE_FACTOR, GRID_SCALE_FACTOR,
GRID_VERSION, GRID_VERSION,
GRID_VERSION_KEY, GRID_VERSION_KEY,
@@ -192,10 +193,30 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 }); expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
expect(r.migrated).toBe(true); expect(r.migrated).toBe(true);
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false, newer: false }); expect(migrateGridLayouts(null)).toEqual({
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false, newer: false }); layouts: {},
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false, newer: false }); migrated: false,
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false, newer: false }); newer: false,
canvas: null,
});
expect(migrateGridLayouts(undefined)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts(42)).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
expect(migrateGridLayouts([1, 2])).toEqual({
layouts: {},
migrated: false,
newer: false,
canvas: null,
});
}); });
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => { it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
@@ -209,3 +230,58 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
expect(future.layouts).toEqual(alt); expect(future.layouts).toEqual(alt);
}); });
}); });
describe('grid-layout-migration: Leinwand `__canvas` (Dashboard wie ein Bild mitskalieren)', () => {
const v3 = () => ({ lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], [GRID_VERSION_KEY]: GRID_VERSION });
it('liest eine gueltige Leinwand und laesst sie aus `layouts` heraus', () => {
const r = migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 1600, h: 820 } });
expect(r.canvas).toEqual({ w: 1600, h: 820 });
expect(Object.keys(r.layouts)).toEqual(['lg']);
expect(r.migrated).toBe(false);
});
it('fehlende oder ungueltige Werte -> null', () => {
const bad: unknown[] = [
undefined,
null,
'x',
[1600, 800],
{ w: 1600 },
{ w: '1600', h: 800 },
{ w: Number.NaN, h: 800 },
{ w: Number.POSITIVE_INFINITY, h: 800 },
{ w: 767, h: 800 },
{ w: 1600, h: 199 },
];
for (const value of bad) {
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: value }).canvas).toBeNull();
}
// Grenzwerte sind gueltig.
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 768, h: 200 } }).canvas).toEqual({
w: 768,
h: 200,
});
});
it('withGridVersion schreibt die Leinwand nur, wenn eine da ist', () => {
const { layouts } = migrateGridLayouts(v3());
expect(withGridVersion(layouts)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, null)).not.toHaveProperty(GRID_CANVAS_KEY);
expect(withGridVersion(layouts, { w: 1920, h: 950 })).toEqual({
...layouts,
[GRID_VERSION_KEY]: GRID_VERSION,
[GRID_CANVAS_KEY]: { w: 1920, h: 950 },
});
});
it('Idempotenz mit Leinwand: Laden -> Speichern -> Laden liefert dasselbe, nichts skaliert', () => {
const once = migrateGridLayouts({ ...altLayouts(), [GRID_CANVAS_KEY]: { w: 1400, h: 700 } });
expect(once.migrated).toBe(true);
expect(once.canvas).toEqual({ w: 1400, h: 700 });
const twice = migrateGridLayouts(withGridVersion(once.layouts, once.canvas));
expect(twice.migrated).toBe(false);
expect(twice.layouts).toEqual(once.layouts);
expect(twice.canvas).toEqual(once.canvas);
});
});
+51 -6
View File
@@ -41,6 +41,19 @@
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde * wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
* sie beim naechsten Laden ein zweites Mal skalieren. * sie beim naechsten Laden ein zweites Mal skalieren.
* *
* Leinwand (Bezugsgroesse, "wie ein Bild mitskalieren"): Zusaetzlich kann das
* JSON den Schluessel `__canvas: { w, h }` tragen — die verfuegbare Flaeche
* (Pixel), auf der die Anordnung eingerichtet wurde. Das Raster zeichnet
* sich auf JEDEM Desktop-Bildschirm mit genau dieser Breite und skaliert das
* Ergebnis per `transform: scale` in die dortige Flaeche (siehe
* dashboard-grid.tsx). Wie der Marker lebt `__canvas` nur im JSON, nicht in
* `layouts`: `migrateGridLayouts` liefert ihn getrennt als `canvas`,
* `withGridVersion(layouts, canvas)` schreibt ihn zurueck — JEDER
* Speicherpfad muss ihn mitgeben, sonst geht er verloren. Kein neuer
* `GRID_VERSION`: aeltere Programmstaende ueberspringen den Schluessel, weil
* sein Wert kein Array ist (`!Array.isArray(value)` unten) — und schreiben
* ihn beim Speichern nicht zurueck; dann wird er einfach neu erfasst.
*
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API * Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem * das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne * GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
@@ -50,6 +63,17 @@
export const GRID_VERSION = 3; export const GRID_VERSION = 3;
export const GRID_VERSION_KEY = '__gridVersion'; export const GRID_VERSION_KEY = '__gridVersion';
export const GRID_SCALE_FACTOR = 2; export const GRID_SCALE_FACTOR = 2;
export const GRID_CANVAS_KEY = '__canvas';
/** Unter dieser Breite gilt die Handy-/Schmalansicht, dort wird nicht skaliert. */
export const CANVAS_MIN_WIDTH = 768;
/** Kleinere Hoehen sind Messfehler (Fenster kurz zusammengeklappt o. ae.). */
export const CANVAS_MIN_HEIGHT = 200;
/** Bezugsflaeche in Pixeln, auf der die Anordnung eingerichtet wurde. */
export interface GridCanvas {
w: number;
h: number;
}
export interface GridLayoutItem { export interface GridLayoutItem {
i: string; i: string;
@@ -74,6 +98,19 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
/**
* Prueft eine Leinwand: endliche Zahlen, w >= CANVAS_MIN_WIDTH,
* h >= CANVAS_MIN_HEIGHT. Alles andere -> null (wird dann neu erfasst).
*/
export function parseGridCanvas(value: unknown): GridCanvas | null {
if (!isPlainObject(value)) return null;
const { w, h } = value;
if (typeof w !== 'number' || typeof h !== 'number') return null;
if (!Number.isFinite(w) || !Number.isFinite(h)) return null;
if (w < CANVAS_MIN_WIDTH || h < CANVAS_MIN_HEIGHT) return null;
return { w, h };
}
/** /**
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen * Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob * Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
@@ -84,9 +121,11 @@ export function migrateGridLayouts(raw: unknown): {
migrated: boolean; migrated: boolean;
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */ /** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
newer: boolean; newer: boolean;
/** Gueltige Leinwand aus `__canvas`, sonst null. */
canvas: GridCanvas | null;
} { } {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
return { layouts: {}, migrated: false, newer: false }; return { layouts: {}, migrated: false, newer: false, canvas: null };
} }
const markerValue = raw[GRID_VERSION_KEY]; const markerValue = raw[GRID_VERSION_KEY];
@@ -100,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): {
let migrated = false; let migrated = false;
for (const key of Object.keys(raw)) { for (const key of Object.keys(raw)) {
if (key === GRID_VERSION_KEY) continue; if (key === GRID_VERSION_KEY || key === GRID_CANVAS_KEY) continue;
const value = raw[key]; const value = raw[key];
if (!Array.isArray(value)) continue; if (!Array.isArray(value)) continue;
@@ -121,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): {
}); });
} }
return { layouts, migrated, newer }; return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) };
} }
/** /**
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern. * Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern
* an, ohne die Eingabe zu veraendern.
*/ */
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> { export function withGridVersion(
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION }; layouts: GridLayouts,
canvas?: GridCanvas | null,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
return result;
} }
@@ -54,6 +54,7 @@ beforeEach(() => {
isLoading: false, isLoading: false,
error: null, error: null,
layoutFromNewerVersion: false, layoutFromNewerVersion: false,
canvas: null,
}); });
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]); vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
@@ -454,3 +455,106 @@ describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)'
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false }); expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
}); });
}); });
describe('dashboard-store — Leinwand (Dashboard wie ein Bild mitskalieren)', () => {
const V3 = { lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], __gridVersion: 3 };
it('Test 24: Reiter ohne Leinwand -> captureCanvas setzt sie und speichert SOFORT mit __canvas und Marker', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().captureCanvas({ w: 1600.4, h: 820.2 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1600, h: 820 },
});
// Danach aendert sie sich nicht mehr automatisch.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
expect(api.saveLayout).toHaveBeenCalledTimes(1);
});
it('Test 25: geladene Leinwand steht im Zustand, wird nicht neu erfasst, und saveLayout schreibt sie mit', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __canvas: { w: 1920, h: 950 } });
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).not.toHaveBeenCalled();
await useDashboardStore.getState().saveLayout();
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
lg: V3.lg,
__gridVersion: 3,
__canvas: { w: 1920, h: 950 },
});
});
it('Test 26: Sofort-Speichern nach der Umrechnung behaelt die Leinwand', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
__canvas: { w: 1400, h: 700 },
});
await useDashboardStore.getState().loadDashboard();
expect(api.saveLayout).toHaveBeenCalledWith(
'dash-1',
expect.objectContaining({ __gridVersion: 3, __canvas: { w: 1400, h: 700 } }),
);
});
it('Test 27: Reiterwechsel setzt die Leinwand des neuen Reiters (auch null)', async () => {
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
vi.mocked(api.fetchLayout).mockImplementation(async (id: string) =>
id === 'dash-1' ? { ...V3, __canvas: { w: 1920, h: 950 } } : { ...V3 },
);
await useDashboardStore.getState().loadDashboard();
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
await useDashboardStore.getState().selectDashboard('dash-2');
expect(useDashboardStore.getState().canvas).toBeNull();
// Erfassen gilt nun fuer dash-2.
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
expect(api.saveLayout).toHaveBeenLastCalledWith(
'dash-2',
expect.objectContaining({ __canvas: { w: 1280, h: 700 } }),
);
await useDashboardStore.getState().selectDashboard('dash-1');
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
});
it('Test 28: nicht erfassen bei Anordnung eines neueren Programmstands oder bei zu kleiner Flaeche', async () => {
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __gridVersion: 99 });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
useDashboardStore.setState({ activeDashboardId: null });
await useDashboardStore.getState().loadDashboard();
await useDashboardStore.getState().captureCanvas({ w: 767, h: 800 });
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 150 });
expect(useDashboardStore.getState().canvas).toBeNull();
expect(api.saveLayout).not.toHaveBeenCalled();
});
it('Test 29: scheitert das Speichern beim Erfassen, bleibt die Leinwand im Zustand, console.error einmal', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
await useDashboardStore.getState().loadDashboard();
vi.mocked(api.saveLayout).mockRejectedValueOnce(new Error('offline'));
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 800 });
expect(useDashboardStore.getState().error).toBeNull();
expect(errorSpy).toHaveBeenCalledTimes(1);
});
});
+62 -6
View File
@@ -3,7 +3,14 @@ import type { WidgetType } from '@/components/dashboard/widget-registry';
import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry'; import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import type { DashboardTab } from '@/lib/dashboard-api'; import type { DashboardTab } from '@/lib/dashboard-api';
import * as api from '@/lib/dashboard-api'; import * as api from '@/lib/dashboard-api';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration'; import {
CANVAS_MIN_HEIGHT,
CANVAS_MIN_WIDTH,
type GridCanvas,
migrateGridLayouts,
parseGridCanvas,
withGridVersion,
} from '@/lib/grid-layout-migration';
export interface WidgetInstance { export interface WidgetInstance {
id: string; id: string;
@@ -28,6 +35,12 @@ interface DashboardState {
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen. * Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
*/ */
layoutFromNewerVersion: boolean; layoutFromNewerVersion: boolean;
/**
* Leinwand des aktiven Reiters (Bezugsflaeche in Pixeln), siehe
* `captureCanvas`. Gehoert wie `layouts` zum aktiven Reiter; null =
* noch nicht erfasst.
*/
canvas: GridCanvas | null;
setEditMode: (mode: boolean) => void; setEditMode: (mode: boolean) => void;
updateLayouts: (layouts: Record<string, unknown>) => void; updateLayouts: (layouts: Record<string, unknown>) => void;
@@ -38,6 +51,8 @@ interface DashboardState {
loadDashboard: () => Promise<void>; loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>; saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>; selectDashboard: (id: string) => Promise<void>;
/** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
captureCanvas: (area: GridCanvas) => Promise<void>;
createDashboard: () => Promise<void>; createDashboard: () => Promise<void>;
renameDashboard: (id: string, name: string) => Promise<void>; renameDashboard: (id: string, name: string) => Promise<void>;
deleteDashboard: (id: string) => Promise<void>; deleteDashboard: (id: string) => Promise<void>;
@@ -92,6 +107,14 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert * ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums * dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
* Neuladen. Der Merker gilt je geladenem Reiter. * Neuladen. Der Merker gilt je geladenem Reiter.
*
* Leinwand (Dashboard wie ein Bild mitskalieren): `canvas` ist die Flaeche,
* auf der der Reiter eingerichtet wurde. Sie lebt im gespeicherten JSON als
* `__canvas` und wird — wie der Marker — an JEDER Speicherstelle mit
* `withGridVersion(layouts, canvas)` zurueckgeschrieben (Sofort-Speichern nach
* der Umrechnung, `saveLayout`, `captureCanvas`); fehlt sie dort, ist sie
* verloren und wird beim naechsten Oeffnen am dann benutzten Bildschirm neu
* erfasst.
*/ */
export const useDashboardStore = create<DashboardState>()((set, get) => ({ export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [], dashboards: [],
@@ -104,6 +127,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
isLoading: false, isLoading: false,
error: null, error: null,
layoutFromNewerVersion: false, layoutFromNewerVersion: false,
canvas: null,
setEditMode: (mode: boolean) => { setEditMode: (mode: boolean) => {
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten. // quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
@@ -214,11 +238,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
api.fetchLayout(first.id), api.fetchLayout(first.id),
api.fetchWidgets(first.id), api.fetchWidgets(first.id),
]); ]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts); const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({ set({
dashboards, dashboards,
activeDashboardId: first.id, activeDashboardId: first.id,
layouts: migratedLayouts, layouts: migratedLayouts,
canvas,
widgets, widgets,
isLoading: false, isLoading: false,
layoutFromNewerVersion: newer, layoutFromNewerVersion: newer,
@@ -229,7 +254,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint. // try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) { if (migrated) {
try { try {
await api.saveLayout(first.id, withGridVersion(migratedLayouts)); await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
} catch (err) { } catch (err) {
console.error('Failed to persist migrated layout:', err); console.error('Failed to persist migrated layout:', err);
} }
@@ -249,7 +274,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert. // sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
if (get().layoutFromNewerVersion) return; if (get().layoutFromNewerVersion) return;
try { try {
await api.saveLayout(dashboardId, withGridVersion(get().layouts)); await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
set({ isDirty: false }); set({ isDirty: false });
} catch (err) { } catch (err) {
console.error('Failed to save layout:', err); console.error('Failed to save layout:', err);
@@ -275,10 +300,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
set({ isSwitchingDashboard: true, error: null }); set({ isSwitchingDashboard: true, error: null });
try { try {
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]); const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
const { layouts: migratedLayouts, migrated, newer } = migrateGridLayouts(rawLayouts); const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
set({ set({
activeDashboardId: id, activeDashboardId: id,
layouts: migratedLayouts, layouts: migratedLayouts,
canvas,
widgets, widgets,
isDirty: false, isDirty: false,
isSwitchingDashboard: false, isSwitchingDashboard: false,
@@ -287,7 +313,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
}); });
if (migrated) { if (migrated) {
try { try {
await api.saveLayout(id, withGridVersion(migratedLayouts)); await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
} catch (err) { } catch (err) {
console.error('Failed to persist migrated layout:', err); console.error('Failed to persist migrated layout:', err);
} }
@@ -298,6 +324,36 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
} }
}, },
/**
* Leinwand erfassen (Dashboard wie ein Bild mitskalieren). Das Raster ruft
* das auf, solange der aktive Reiter keine Leinwand hat und die verfuegbare
* Flaeche Desktop-Breite hat. Die Flaeche wird EINMAL uebernommen und
* sofort gespeichert; danach aendert sie sich nicht mehr automatisch.
* Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus — auf jedem
* anderen Bildschirm wird es gleichmaessig (auch die Schrift) auf dessen
* Flaeche verkleinert oder vergroessert, ohne Scrollen.
*
* Nichts geschieht bei vorhandener Leinwand, ungueltiger Flaeche (Handy,
* Messfehler) oder einer Anordnung eines neueren Programmstands (die wird
* nie gespeichert). Eigener try/catch wie beim Sofort-Speichern nach der
* Umrechnung: ein Speicherfehler laesst die Leinwand im Zustand stehen, ein
* spaeteres `saveLayout` schreibt sie dann mit.
*/
captureCanvas: async (area: GridCanvas) => {
const state = get();
const dashboardId = state.activeDashboardId;
if (!dashboardId || state.canvas || state.layoutFromNewerVersion) return;
if (area.w < CANVAS_MIN_WIDTH || area.h < CANVAS_MIN_HEIGHT) return;
const canvas = parseGridCanvas({ w: Math.round(area.w), h: Math.round(area.h) });
if (!canvas) return;
set({ canvas });
try {
await api.saveLayout(dashboardId, withGridVersion(state.layouts, canvas));
} catch (err) {
console.error('Failed to persist dashboard canvas:', err);
}
},
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */ /** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
createDashboard: async () => { createDashboard: async () => {
try { try {
+2
View File
@@ -60,6 +60,8 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln. Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
**Gleiches Bild auf jedem Bildschirm:** Tessera merkt sich je Dashboard die Größe der Fläche auf dem Bildschirm, an dem Sie es zum ersten Mal geöffnet haben. Auf jedem anderen Bildschirm – etwa am Laptop statt am großen Monitor – zeigt es dasselbe Dashboard maßstäblich verkleinert oder vergrößert, Kacheln und Schrift gleichermaßen, sodass es vollständig hineinpasst. Hat der Bildschirm eine andere Form, bleibt rechts oder unten etwas Platz frei. Bearbeiten können Sie das Dashboard auf jedem Bildschirm. Auf dem Handy (schmaler als 768 Pixel) ordnet Tessera die Kacheln weiterhin untereinander an.
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem: **Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter. - Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung). - Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).