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:
@@ -48,6 +48,9 @@ interface ActiveModule {
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||||
|
// Seitlicher Versatz der zentrierten Kacheln — die Zeile mit Begruessung
|
||||||
|
// und Befehlsleiste rueckt mit, damit sie buendig ueber den Kacheln steht.
|
||||||
|
const [gridInset, setGridInset] = useState(0);
|
||||||
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
|
// Hintergrund je Benutzer (Design „Mosaik“, Prototyp mit localStorage).
|
||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const { background, choose: chooseBackground } = useDashboardBackground(userId);
|
const { background, choose: chooseBackground } = useDashboardBackground(userId);
|
||||||
@@ -143,7 +146,10 @@ export default function DashboardPage() {
|
|||||||
links, Befehlsleiste rechts (Fluent „command bar“) statt des
|
links, Befehlsleiste rechts (Fluent „command bar“) statt des
|
||||||
frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen
|
frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen
|
||||||
beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
|
beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */}
|
||||||
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2 px-3 pb-1 pt-2">
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-3 pt-1 transition-[padding] duration-200 ease-out motion-reduce:transition-none"
|
||||||
|
style={gridInset > 0 ? { paddingInline: 12 + gridInset } : undefined}
|
||||||
|
>
|
||||||
<div className="dashboard-chrome min-w-0 rounded-lg">
|
<div className="dashboard-chrome min-w-0 rounded-lg">
|
||||||
<DashboardGreeting />
|
<DashboardGreeting />
|
||||||
</div>
|
</div>
|
||||||
@@ -198,6 +204,7 @@ export default function DashboardPage() {
|
|||||||
onRemoveWidget={removeWidget}
|
onRemoveWidget={removeWidget}
|
||||||
onOpenCatalog={() => setCatalogOpen(true)}
|
onOpenCatalog={() => setCatalogOpen(true)}
|
||||||
onQuickAdd={(type) => addWidget(type)}
|
onQuickAdd={(type) => addWidget(type)}
|
||||||
|
onInsetChange={setGridInset}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,70 @@ input[type="checkbox"], input[type="radio"] {
|
|||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Seitenleiste (Runde 3): stehen die Dashboard-Reiter unter „Dashboard“,
|
||||||
|
traegt nur der gewaehlte Reiter die Auswahl — der Obereintrag bleibt
|
||||||
|
neutral, sonst waere „Dashboard“ doppelt markiert. */
|
||||||
|
li:has(> #sidebar-dashboard-slot > nav) > a {
|
||||||
|
background-color: transparent;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
li:has(> #sidebar-dashboard-slot > nav) > a:hover {
|
||||||
|
background-color: var(--sidebar-hover);
|
||||||
|
}
|
||||||
|
li:has(> #sidebar-dashboard-slot > nav) > a [data-testid="nav-selection-pill"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Runde 3 — mehr Leben in den Kacheln, ohne bunt zu werden.
|
||||||
|
Symbol-Chip: Flaeche und Symbol aus der persoenlichen Akzentfarbe. */
|
||||||
|
.widget-icon-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.625rem;
|
||||||
|
height: 1.625rem;
|
||||||
|
border-radius: 0.4375rem;
|
||||||
|
background-color: color-mix(in oklab, var(--primary) 24%, var(--card));
|
||||||
|
color: color-mix(in oklab, var(--primary) 38%, oklch(0.27 0.02 90));
|
||||||
|
}
|
||||||
|
.dark .widget-icon-chip {
|
||||||
|
background-color: color-mix(in oklab, var(--primary) 16%, var(--card));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Karte hebt sich beim Zeigen leicht an (nur Maus, nur Ansicht). */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.widget-card {
|
||||||
|
transition: box-shadow 160ms ease-out, translate 160ms ease-out;
|
||||||
|
}
|
||||||
|
.widget-card:not(.widget-drag-handle):hover {
|
||||||
|
translate: 0 -1px;
|
||||||
|
box-shadow: 0 0 2px oklch(0 0 0 / 0.12), 0 6px 16px oklch(0 0 0 / 0.12);
|
||||||
|
}
|
||||||
|
.dark .widget-card:not(.widget-drag-handle):hover {
|
||||||
|
box-shadow: 0 0 0 1px oklch(1 0 0 / 0.08), 0 8px 20px oklch(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beim Laden blenden die Kacheln gestaffelt ein (je 40 ms, --enter-delay). */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.widget-card {
|
||||||
|
animation: widget-enter 320ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||||
|
animation-delay: var(--enter-delay, 0ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes widget-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
|
/* Aktionen der Kachel erscheinen erst beim Zeigen oder mit Tastaturfokus —
|
||||||
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
|
nur auf Geraeten mit Maus; auf Touch bleiben sie sichtbar. */
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
|||||||
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
|
{option('none', t('none'), NO_BACKGROUND, { backgroundColor: 'var(--background)' },
|
||||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><circle cx="12" cy="12" r="8" /><path d="m6.5 17.5 11-11" /></svg>,
|
||||||
)}
|
)}
|
||||||
{BACKGROUND_PRESETS.map((preset) => {
|
{BACKGROUND_PRESETS.filter((preset) => !dark || preset.dark !== null).map((preset) => {
|
||||||
const css = dark ? preset.dark : preset.light;
|
const css = (dark ? preset.dark : preset.light) ?? preset.light;
|
||||||
return option(
|
return option(
|
||||||
preset.id,
|
preset.id,
|
||||||
t(`presets.${preset.labelKey}`),
|
t(`presets.${preset.labelKey}`),
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function greetingName(displayName: string | null | undefined, username: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2): „Guten
|
* Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2/3): „Guten
|
||||||
* Morgen, Max“ plus Wochentag und Datum. Keine Kennzahlen. Die Uhrzeit wird
|
* Morgen, Max“ plus Wochentag und Datum in einer Zeile. Keine Kennzahlen. Die Uhrzeit wird
|
||||||
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
|
* erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und
|
||||||
* jede Minute aufgefrischt.
|
* jede Minute aufgefrischt.
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +36,7 @@ export function DashboardGreeting() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!now) return <div className="h-14" aria-hidden="true" />;
|
if (!now) return <div className="h-8" aria-hidden="true" />;
|
||||||
|
|
||||||
const name = greetingName(user?.displayName, user?.username);
|
const name = greetingName(user?.displayName, user?.username);
|
||||||
const period = greetingPeriod(now);
|
const period = greetingPeriod(now);
|
||||||
@@ -46,12 +46,14 @@ export function DashboardGreeting() {
|
|||||||
month: 'long',
|
month: 'long',
|
||||||
}).format(now);
|
}).format(now);
|
||||||
|
|
||||||
|
// Runde 3: eine Zeile auf Hoehe der Befehlsleiste statt zweizeiliger
|
||||||
|
// Ueberschrift — die Begruessung bleibt, kostet aber kaum noch Platz.
|
||||||
return (
|
return (
|
||||||
<div data-testid="dashboard-greeting" className="min-w-0">
|
<div data-testid="dashboard-greeting" className="flex h-8 min-w-0 items-baseline gap-2 leading-8">
|
||||||
<h1 className="truncate text-2xl font-semibold text-foreground">
|
<h1 className="truncate text-base font-semibold text-foreground">
|
||||||
{name ? t(`${period}Named`, { name }) : t(period)}
|
{name ? t(`${period}Named`, { name }) : t(period)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">{dateLine}</p>
|
<p className="hidden shrink-0 text-sm text-muted-foreground sm:block">{dateLine}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Children, isValidElement } from 'react';
|
|||||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
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 { centeringOffset, RESIZE_AXIS_FALLBACK } 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', () => ({}));
|
||||||
@@ -481,3 +482,30 @@ describe('DashboardGrid', () => {
|
|||||||
expect(captured.props?.width).toBe(1600);
|
expect(captured.props?.width).toBe(1600);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
|
||||||
|
// Favoriten (0,0,6x10) mit einer Kachel direkt darunter (0,10,6x8).
|
||||||
|
const layout = [
|
||||||
|
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
|
||||||
|
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
|
||||||
|
];
|
||||||
|
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
|
||||||
|
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
|
||||||
|
|
||||||
|
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
|
||||||
|
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst eine freie Groesse unveraendert', () => {
|
||||||
|
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('centeringOffset (Design „Mosaik“, Runde 3)', () => {
|
||||||
|
it('stellt belegte Spalten mittig und laesst eine volle Anordnung stehen', () => {
|
||||||
|
// 24 Spalten auf 1212 px: Spalte 38 px + 12 px Abstand = 50 px je Spalte.
|
||||||
|
expect(centeringOffset([{ x: 0, w: 12 }], 24, 1212)).toBe(300);
|
||||||
|
expect(centeringOffset([{ x: 12, w: 12 }], 24, 1212)).toBe(0);
|
||||||
|
expect(centeringOffset([], 24, 1212)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Responsive, noCompactor } from 'react-grid-layout';
|
import { Responsive, noCompactor } from 'react-grid-layout';
|
||||||
import type { Compactor, ResponsiveLayouts } 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-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';
|
||||||
@@ -44,6 +45,74 @@ export const WIDGET_DRAG_CANCEL_SELECTOR =
|
|||||||
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
|
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
|
||||||
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
|
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 {
|
interface LayoutItemShape {
|
||||||
i: string;
|
i: string;
|
||||||
x: number;
|
x: number;
|
||||||
@@ -64,6 +133,8 @@ 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;
|
||||||
|
/** Meldet den seitlichen Versatz der zentrierten Ansicht (0 im Bearbeitungsmodus). */
|
||||||
|
onInsetChange?: (px: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
||||||
@@ -132,6 +203,7 @@ export function DashboardGrid({
|
|||||||
onRemoveWidget,
|
onRemoveWidget,
|
||||||
onOpenCatalog,
|
onOpenCatalog,
|
||||||
onQuickAdd,
|
onQuickAdd,
|
||||||
|
onInsetChange,
|
||||||
}: DashboardGridProps) {
|
}: DashboardGridProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const [width, setWidth] = useState(1200);
|
const [width, setWidth] = useState(1200);
|
||||||
@@ -212,6 +284,17 @@ export function DashboardGrid({
|
|||||||
// stabil bleibt.
|
// stabil bleibt.
|
||||||
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
|
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
|
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
|
||||||
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
||||||
// Hinzufuegen und der Hauptknopf zum Katalog.
|
// Hinzufuegen und der Hauptknopf zum Katalog.
|
||||||
@@ -238,7 +321,7 @@ export function DashboardGrid({
|
|||||||
onClick={() => onQuickAdd(type)}
|
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"
|
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)}
|
{t(WIDGET_REGISTRY[type].nameKey)}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -257,14 +340,22 @@ export function DashboardGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<Responsive
|
||||||
width={width}
|
width={width}
|
||||||
breakpoints={BREAKPOINTS}
|
breakpoints={BREAKPOINTS}
|
||||||
cols={COLS}
|
cols={COLS}
|
||||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||||
rowHeight={20}
|
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
|
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
||||||
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
||||||
dragConfig={{
|
dragConfig={{
|
||||||
@@ -279,7 +370,7 @@ export function DashboardGrid({
|
|||||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||||
>
|
>
|
||||||
{widgets.map((widget) => {
|
{widgets.map((widget, index) => {
|
||||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -299,11 +390,13 @@ export function DashboardGrid({
|
|||||||
widget={widget}
|
widget={widget}
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
onRemove={onRemoveWidget}
|
onRemove={onRemoveWidget}
|
||||||
|
enterIndex={index}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Responsive>
|
</Responsive>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
import { HEADER_CENTER_SLOT_ID, SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
|
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||||
|
|
||||||
interface DashboardTabsProps {
|
interface DashboardTabsProps {
|
||||||
@@ -46,6 +47,30 @@ interface DragState {
|
|||||||
id: string;
|
id: string;
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
startX: number;
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wo die Reiter stehen (Design „Mosaik“, Runde 3): in der aufgeklappten
|
||||||
|
* Seitenleiste am Desktop als Liste unter „Dashboard“ — dort kosten sie
|
||||||
|
* keinen Platz ueber den Kacheln, und die Kopfzeile zeigt nur den Namen des
|
||||||
|
* gewaehlten Dashboards. Eingeklappt oder auf dem Handy gibt es diese Liste
|
||||||
|
* nicht, dann wie bisher als Umschalter in der Kopfzeile.
|
||||||
|
*/
|
||||||
|
type Placement = 'sidebar' | 'header';
|
||||||
|
|
||||||
|
function useTabsPlacement(): Placement {
|
||||||
|
const isCollapsed = useSidebarStore((st) => st.isCollapsed);
|
||||||
|
const [isDesktop, setIsDesktop] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window.matchMedia !== 'function') return;
|
||||||
|
const mq = window.matchMedia('(min-width: 768px)');
|
||||||
|
const update = () => setIsDesktop(mq.matches);
|
||||||
|
update();
|
||||||
|
mq.addEventListener('change', update);
|
||||||
|
return () => mq.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
return isDesktop && !isCollapsed ? 'sidebar' : 'header';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,16 +82,16 @@ interface DragState {
|
|||||||
function computeReorderedIds(
|
function computeReorderedIds(
|
||||||
order: string[],
|
order: string[],
|
||||||
draggedId: string,
|
draggedId: string,
|
||||||
rects: Map<string, { left: number; width: number }>,
|
rects: Map<string, { start: number; size: number }>,
|
||||||
clientX: number,
|
pointer: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const others = order.filter((id) => id !== draggedId);
|
const others = order.filter((id) => id !== draggedId);
|
||||||
let insertAt = others.length;
|
let insertAt = others.length;
|
||||||
for (let i = 0; i < others.length; i++) {
|
for (let i = 0; i < others.length; i++) {
|
||||||
const rect = rects.get(others[i]);
|
const rect = rects.get(others[i]);
|
||||||
if (!rect) continue;
|
if (!rect) continue;
|
||||||
const midpoint = rect.left + rect.width / 2;
|
const midpoint = rect.start + rect.size / 2;
|
||||||
if (clientX < midpoint) {
|
if (pointer < midpoint) {
|
||||||
insertAt = i;
|
insertAt = i;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -131,13 +156,20 @@ export function DashboardTabs({
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const hintId = useId();
|
const hintId = useId();
|
||||||
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
const [slot, setSlot] = useState<HTMLElement | null>(null);
|
||||||
|
const [headerSlot, setHeaderSlot] = useState<HTMLElement | null>(null);
|
||||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||||
|
const placement = useTabsPlacement();
|
||||||
|
const vertical = placement === 'sidebar';
|
||||||
|
|
||||||
// Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben
|
// Einhaengepunkte suchen. Kopfzeile und Seitenleiste stehen im selben
|
||||||
// Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM.
|
// Seitenrahmen (AppShell) und sind nach dem ersten Zeichnen im DOM; der
|
||||||
|
// Punkt der Seitenleiste entsteht bzw. verschwindet mit dem Ein-/Ausklappen
|
||||||
|
// im selben Durchgang, der `placement` aendert.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSlot(document.getElementById(HEADER_CENTER_SLOT_ID));
|
const header = document.getElementById(HEADER_CENTER_SLOT_ID);
|
||||||
}, []);
|
setHeaderSlot(header);
|
||||||
|
setSlot(vertical ? (document.getElementById(SIDEBAR_DASHBOARD_SLOT_ID) ?? header) : header);
|
||||||
|
}, [vertical]);
|
||||||
|
|
||||||
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
|
/** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */
|
||||||
const measureOverflow = useCallback(() => {
|
const measureOverflow = useCallback(() => {
|
||||||
@@ -200,7 +232,7 @@ export function DashboardTabs({
|
|||||||
|
|
||||||
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
|
function handlePointerDown(e: PointerEvent<HTMLDivElement>, tabId: string) {
|
||||||
if (e.button !== 0 || renamingId) return;
|
if (e.button !== 0 || renamingId) return;
|
||||||
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX };
|
dragRef.current = { id: tabId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY };
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
|
||||||
@@ -209,7 +241,8 @@ export function DashboardTabs({
|
|||||||
|
|
||||||
const wasNotDragging = previewOrder === null;
|
const wasNotDragging = previewOrder === null;
|
||||||
if (wasNotDragging) {
|
if (wasNotDragging) {
|
||||||
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD_PX) return;
|
const moved = vertical ? e.clientY - drag.startY : e.clientX - drag.startX;
|
||||||
|
if (Math.abs(moved) < DRAG_THRESHOLD_PX) return;
|
||||||
hasDraggedRef.current = true;
|
hasDraggedRef.current = true;
|
||||||
capturePointer(e.currentTarget, drag.pointerId);
|
capturePointer(e.currentTarget, drag.pointerId);
|
||||||
}
|
}
|
||||||
@@ -221,15 +254,18 @@ export function DashboardTabs({
|
|||||||
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
|
// wird) keine Verschiebung zeigen, weil der ERSTE Move-Aufruf nur in den
|
||||||
// Ziehzustand wechselt, ohne die Position auszuwerten.
|
// Ziehzustand wechselt, ohne die Position auszuwerten.
|
||||||
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
|
const baseOrder = previewOrder ?? dashboards.map((d) => d.id);
|
||||||
const rects = new Map<string, { left: number; width: number }>();
|
const rects = new Map<string, { start: number; size: number }>();
|
||||||
for (const id of baseOrder) {
|
for (const id of baseOrder) {
|
||||||
const el = tabRefs.current.get(id);
|
const el = tabRefs.current.get(id);
|
||||||
if (el) {
|
if (el) {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
rects.set(id, { left: rect.left, width: rect.width });
|
rects.set(
|
||||||
|
id,
|
||||||
|
vertical ? { start: rect.top, size: rect.height } : { start: rect.left, size: rect.width },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const next = computeReorderedIds(baseOrder, drag.id, rects, e.clientX);
|
const next = computeReorderedIds(baseOrder, drag.id, rects, vertical ? e.clientY : e.clientX);
|
||||||
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
|
if (wasNotDragging || next.join('\u0000') !== baseOrder.join('\u0000')) {
|
||||||
setPreviewOrder(next);
|
setPreviewOrder(next);
|
||||||
}
|
}
|
||||||
@@ -287,8 +323,10 @@ export function DashboardTabs({
|
|||||||
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
function handleTabKeyDown(e: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||||
const count = orderedTabs.length;
|
const count = orderedTabs.length;
|
||||||
let next: number;
|
let next: number;
|
||||||
if (e.key === 'ArrowRight') next = (index + 1) % count;
|
const forward = vertical ? 'ArrowDown' : 'ArrowRight';
|
||||||
else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count;
|
const backward = vertical ? 'ArrowUp' : 'ArrowLeft';
|
||||||
|
if (e.key === forward) next = (index + 1) % count;
|
||||||
|
else if (e.key === backward) next = (index - 1 + count) % count;
|
||||||
else if (e.key === 'Home') next = 0;
|
else if (e.key === 'Home') next = 0;
|
||||||
else if (e.key === 'End') next = count - 1;
|
else if (e.key === 'End') next = count - 1;
|
||||||
else return;
|
else return;
|
||||||
@@ -492,6 +530,153 @@ export function DashboardTabs({
|
|||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Senkrechte Liste unter „Dashboard“ in der Seitenleiste (Runde 3).
|
||||||
|
// Gleiche Bedienung wie die Leiste: Klick waehlt, Ziehen sortiert (hier
|
||||||
|
// senkrecht), Pfeiltasten hoch/runter wandern, Umbenennen/Loeschen/Neu nur
|
||||||
|
// im Bearbeitungsmodus. Mit nur einem Dashboard und ausserhalb des
|
||||||
|
// Bearbeitungsmodus bleibt die Liste weg — dann gibt es nichts zu waehlen.
|
||||||
|
const pencilIcon = (
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const closeIcon = (
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const showList = dashboards.length > 1 || isEditMode;
|
||||||
|
const list = showList ? (
|
||||||
|
<nav aria-label={t('tabs.navLabel')} className="ml-[19px] mt-0.5 border-l border-sidebar-border pl-2">
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-describedby={dashboards.length > 1 ? hintId : undefined}
|
||||||
|
title={dashboards.length > 1 ? t('tabs.dragHint') : undefined}
|
||||||
|
className="flex flex-col gap-0.5"
|
||||||
|
>
|
||||||
|
{orderedTabs.map((tab, index) => {
|
||||||
|
const isActive = tab.id === activeDashboardId;
|
||||||
|
const isRenaming = renamingId === tab.id;
|
||||||
|
const isFocusable = isActive || (!hasActive && index === 0);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabRefs.current.set(tab.id, el);
|
||||||
|
else tabRefs.current.delete(tab.id);
|
||||||
|
}}
|
||||||
|
data-tab-index={index}
|
||||||
|
data-testid={`dashboard-tab-${tab.id}`}
|
||||||
|
className={`group relative flex h-8 touch-none items-center rounded-md transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-sidebar-hover font-semibold text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-sidebar-hover hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
onPointerDown={(e) => handlePointerDown(e, tab.id)}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -left-[10px] top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-primary-strong"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isRenaming ? (
|
||||||
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
|
value={draftName}
|
||||||
|
onChange={(e) => setDraftName(e.target.value)}
|
||||||
|
onBlur={commitRename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
commitRename();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxLength={40}
|
||||||
|
aria-label={t('tabs.renameInputLabel')}
|
||||||
|
className="mx-1 h-7 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-sm font-normal text-foreground outline-none focus:border-primary-strong"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) tabButtonRefs.current.set(tab.id, el);
|
||||||
|
else tabButtonRefs.current.delete(tab.id);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
tabIndex={isFocusable ? 0 : -1}
|
||||||
|
onClick={() => handleTabClick(tab.id)}
|
||||||
|
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||||
|
className="flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md px-2 text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<span className="truncate">{tab.name}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isEditMode && isActive && !isRenaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRename(tab)}
|
||||||
|
aria-label={t('tabs.renameButtonLabel')}
|
||||||
|
title={t('tabs.renameButtonLabel')}
|
||||||
|
className="rounded p-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{pencilIcon}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isEditMode && dashboards.length > 1 && !isRenaming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPendingDeleteId(tab.id)}
|
||||||
|
aria-label={t('tabs.deleteButtonLabel')}
|
||||||
|
title={t('tabs.deleteButtonLabel')}
|
||||||
|
className="mr-1 rounded p-1 text-muted-foreground hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{closeIcon}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
className="mt-0.5 flex h-8 w-full items-center justify-start gap-2 whitespace-nowrap rounded-md px-2 text-left text-sm text-muted-foreground transition-colors hover:bg-sidebar-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
{t('tabs.add')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{dashboards.length > 1 && (
|
||||||
|
<span id={hintId} className="sr-only">
|
||||||
|
{t('tabs.dragHint')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Kopfzeile, solange die Reiter in der Seitenleiste stehen: nur der Name
|
||||||
|
// des gewaehlten Dashboards, wie die anderen Seiten ihren Titel zeigen.
|
||||||
|
const activeName = dashboards.find((d) => d.id === activeDashboardId)?.name ?? '';
|
||||||
|
const headerTitle = (
|
||||||
|
<span className="truncate text-sm font-semibold text-appbar-foreground">{activeName}</span>
|
||||||
|
);
|
||||||
|
const inSidebar = vertical && slot !== null && slot !== headerSlot;
|
||||||
|
|
||||||
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
|
// Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile —
|
||||||
// sonst saesse er in deren Stapelkontext.
|
// sonst saesse er in deren Stapelkontext.
|
||||||
const dialog = pendingDeleteTab
|
const dialog = pendingDeleteTab
|
||||||
@@ -542,7 +727,14 @@ export function DashboardTabs({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{createPortal(bar, slot)}
|
{inSidebar ? (
|
||||||
|
<>
|
||||||
|
{list && createPortal(list, slot)}
|
||||||
|
{headerSlot && createPortal(headerTitle, headerSlot)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
createPortal(bar, slot)
|
||||||
|
)}
|
||||||
{dialog}
|
{dialog}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
|||||||
expect(WIDGET_CONSTRAINTS).toEqual({
|
expect(WIDGET_CONSTRAINTS).toEqual({
|
||||||
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
|
clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 },
|
||||||
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
||||||
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
|
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
|
||||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||||
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 },
|
||||||
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
|
favorites: { minW: 1, minH: 3, defaultW: 6, defaultH: 10 },
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export const WIDGET_CONSTRAINTS: Record<
|
|||||||
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 },
|
||||||
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
|
// quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe
|
||||||
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
|
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
|
||||||
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 },
|
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
|
||||||
|
// den Monat nur ein Termin.
|
||||||
|
calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 16 },
|
||||||
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 },
|
||||||
// Phase 8 new widgets (D-01)
|
// Phase 8 new widgets (D-01)
|
||||||
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
|
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
|
||||||
|
|||||||
@@ -283,6 +283,27 @@ export function formatEventDate(event: CalendarEvent): string {
|
|||||||
}).format(start);
|
}).format(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstand des Terminbeginns zu heute in Kalendertagen (0 = heute,
|
||||||
|
* 1 = morgen, negativ = laeuft seit gestern oder frueher). Gezaehlt wird in
|
||||||
|
* Ortszeit, nicht in 24-Stunden-Bloecken.
|
||||||
|
*/
|
||||||
|
export function eventDayOffset(event: CalendarEvent, now: Date = new Date()): number {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
const a = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
|
||||||
|
const b = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
return Math.round((a - b) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kurzes Datum ohne Uhrzeit, „Sa., 03.10.“. */
|
||||||
|
export function formatEventDay(event: CalendarEvent): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
}).format(new Date(event.start));
|
||||||
|
}
|
||||||
|
|
||||||
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
|
/** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */
|
||||||
export function formatEventTime(iso: string): string {
|
export function formatEventTime(iso: string): string {
|
||||||
return new Intl.DateTimeFormat('de-DE', {
|
return new Intl.DateTimeFormat('de-DE', {
|
||||||
|
|||||||
@@ -409,12 +409,15 @@ describe('CalendarWidget', () => {
|
|||||||
expect(items).toHaveLength(2);
|
expect(items).toHaveLength(2);
|
||||||
expect(items[0]).toHaveTextContent('E1');
|
expect(items[0]).toHaveTextContent('E1');
|
||||||
expect(items[1]).toHaveTextContent('E2');
|
expect(items[1]).toHaveTextContent('E2');
|
||||||
expect(items[1]).toHaveTextContent('Raum 2');
|
// Runde 3: einzeilige Zeile, der Ort steht im Tooltip der Zeile.
|
||||||
|
expect(items[1]).toHaveAttribute('title', expect.stringContaining('Raum 2'));
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument();
|
expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
expect(items[0].textContent).toMatch(/\d{2}\.\d{2}\./);
|
// Runde 3: morgen steht als Wort (Akzentfarbe), spaetere Tage als Datum.
|
||||||
|
expect(items[0]).toHaveTextContent('calendar.tomorrow');
|
||||||
|
expect(items[1].textContent).toMatch(/\d{2}\.\d{2}\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
|
it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
buildCalendarDays,
|
buildCalendarDays,
|
||||||
buildDayBadges,
|
buildDayBadges,
|
||||||
computeFetchWindow,
|
computeFetchWindow,
|
||||||
|
eventDayOffset,
|
||||||
formatEventDate,
|
formatEventDate,
|
||||||
|
formatEventDay,
|
||||||
formatEventTime,
|
formatEventTime,
|
||||||
formatMonthLabel,
|
formatMonthLabel,
|
||||||
groupEventsByDate,
|
groupEventsByDate,
|
||||||
@@ -407,30 +409,48 @@ export function CalendarWidget({ config }: WidgetProps) {
|
|||||||
{t('calendar.emptyNoEvents')}
|
{t('calendar.emptyNoEvents')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
|
// Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich,
|
||||||
{upcoming.map((event) => (
|
// Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so
|
||||||
<li key={event.id} className="flex items-stretch gap-2.5 rounded-md px-1.5 py-1 hover:bg-muted">
|
// passen auch in eine kleine Kachel mehrere Termine. „Heute“ und
|
||||||
<span
|
// „Morgen“ stehen in der Akzentfarbe.
|
||||||
data-testid="event-color-dot"
|
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 overflow-y-auto">
|
||||||
className="w-[3px] shrink-0 rounded-full"
|
{upcoming.map((event) => {
|
||||||
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
const offset = eventDayOffset(event);
|
||||||
aria-hidden="true"
|
const dayLabel =
|
||||||
/>
|
offset === 0
|
||||||
<div className="min-w-0 flex-1">
|
? t('calendar.today')
|
||||||
<p className="truncate text-[11px] leading-4 text-muted-foreground">
|
: offset === 1
|
||||||
{formatEventDate(event)}
|
? t('calendar.tomorrow')
|
||||||
</p>
|
: formatEventDay(event);
|
||||||
<p className="truncate text-[13px] font-medium leading-[18px] text-foreground">
|
const soon = offset === 0 || offset === 1;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={event.id}
|
||||||
|
title={[formatEventDate(event), event.title, event.location].filter(Boolean).join(' · ')}
|
||||||
|
className="flex h-[26px] items-center gap-2 rounded-md px-1.5 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-testid="event-color-dot"
|
||||||
|
className="h-3.5 w-[3px] shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`w-[62px] shrink-0 truncate text-[11px] tabular-nums ${
|
||||||
|
soon ? 'font-semibold text-primary-strong' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dayLabel}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||||
{event.title}
|
{event.title}
|
||||||
</p>
|
</span>
|
||||||
{event.location && (
|
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||||
<p className="truncate text-[11px] leading-4 text-muted-foreground">
|
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
|
||||||
{event.location}
|
</span>
|
||||||
</p>
|
</li>
|
||||||
)}
|
);
|
||||||
</div>
|
})}
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
|
* Kleines einfarbiges Symbol je Kacheltyp fuer die Kachel-Kopfzeile
|
||||||
* (Design „Mosaik“, Runde 2): 16 px, gedaempftes Grau, rein dekorativ.
|
* (Design „Mosaik“). Seit Runde 3 als „Chip“: 16-px-Symbol auf einer
|
||||||
|
* 26-px-Flaeche, beides aus der persoenlichen Akzentfarbe abgeleitet
|
||||||
|
* (`.widget-icon-chip` in globals.css) — bringt Farbe in die Kacheln, ohne
|
||||||
|
* bunt zu werden. `plain` ist das nackte graue Symbol (Vorschlagsknoepfe).
|
||||||
*/
|
*/
|
||||||
const PATHS: Record<string, ReactNode> = {
|
const PATHS: Record<string, ReactNode> = {
|
||||||
calendar: (
|
calendar: (
|
||||||
@@ -64,10 +67,10 @@ const PATHS: Record<string, ReactNode> = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WidgetIcon({ type }: { type: string }) {
|
export function WidgetIcon({ type, variant = 'chip' }: { type: string; variant?: 'chip' | 'plain' }) {
|
||||||
const paths = PATHS[type];
|
const paths = PATHS[type];
|
||||||
if (!paths) return null;
|
if (!paths) return null;
|
||||||
return (
|
const svg = (
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-widget-icon={type}
|
data-widget-icon={type}
|
||||||
@@ -80,9 +83,15 @@ export function WidgetIcon({ type }: { type: string }) {
|
|||||||
strokeWidth="1.8"
|
strokeWidth="1.8"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className="shrink-0 text-muted-foreground"
|
className={variant === 'plain' ? 'shrink-0 text-muted-foreground' : 'shrink-0'}
|
||||||
>
|
>
|
||||||
{paths}
|
{paths}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
if (variant === 'plain') return svg;
|
||||||
|
return (
|
||||||
|
<span aria-hidden="true" className="widget-icon-chip">
|
||||||
|
{svg}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
import { WIDGET_REGISTRY, type WidgetType } from '../widget-registry';
|
||||||
import { WidgetIcon } from './widget-icon';
|
import { WidgetIcon } from './widget-icon';
|
||||||
@@ -21,6 +22,8 @@ interface WidgetWrapperProps {
|
|||||||
};
|
};
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
/** Position im Raster — staffelt das Einblenden beim Laden (Runde 3). */
|
||||||
|
enterIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +38,7 @@ interface WidgetWrapperProps {
|
|||||||
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
|
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
|
||||||
* Renders the actual widget component via WIDGET_REGISTRY lookup.
|
* Renders the actual widget component via WIDGET_REGISTRY lookup.
|
||||||
*/
|
*/
|
||||||
export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperProps) {
|
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||||
const WidgetComponent = definition?.component;
|
const WidgetComponent = definition?.component;
|
||||||
@@ -43,12 +46,13 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
|
|||||||
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
// Design „Mosaik“: in der Ansicht eine ruhige Fluent-Karte (hell Schatten,
|
||||||
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
// dunkel Haarlinie), die Akzent-Umrandung nur im Bearbeitungsmodus.
|
||||||
const cardClassName = isEditMode
|
const cardClassName = isEditMode
|
||||||
? 'widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
|
? 'widget-card widget-drag-handle relative h-full w-full cursor-grab overflow-hidden rounded-lg bg-card shadow-md active:cursor-grabbing'
|
||||||
: 'relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
|
: 'widget-card relative h-full w-full overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={cardClassName}
|
className={cardClassName}
|
||||||
|
style={{ '--enter-delay': `${Math.min(enterIndex, 8) * 40}ms` } as CSSProperties}
|
||||||
data-widget-id={widget.id}
|
data-widget-id={widget.id}
|
||||||
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
aria-label={definition ? t(definition.nameKey) : widget.widgetType}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,3 +4,10 @@
|
|||||||
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
* zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter).
|
||||||
*/
|
*/
|
||||||
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
export const HEADER_CENTER_SLOT_ID = 'header-center-slot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einhaengepunkt unter „Dashboard“ in der Seitenleiste (Design „Mosaik“,
|
||||||
|
* Runde 3). Nur in der aufgeklappten Seitenleiste am Desktop vorhanden; dort
|
||||||
|
* zeichnen die Dashboard-Reiter hinein, sonst in {@link HEADER_CENTER_SLOT_ID}.
|
||||||
|
*/
|
||||||
|
export const SIDEBAR_DASHBOARD_SLOT_ID = 'sidebar-dashboard-slot';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
|||||||
import { ModuleTile } from '@/components/modules/module-tile';
|
import { ModuleTile } from '@/components/modules/module-tile';
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
import { useNavStore } from '@/lib/stores/nav-store';
|
import { useNavStore } from '@/lib/stores/nav-store';
|
||||||
|
import { SIDEBAR_DASHBOARD_SLOT_ID } from '@/components/layout/header-slot';
|
||||||
|
|
||||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||||
function SelectionPill() {
|
function SelectionPill() {
|
||||||
@@ -128,7 +129,10 @@ export function Sidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarContent = (
|
// `inDrawer`: die mobile Schublade rendert denselben Inhalt ein zweites
|
||||||
|
// Mal — der Einhaengepunkt der Dashboard-Reiter darf nur einmal im DOM
|
||||||
|
// stehen und gehoert zur Desktop-Leiste.
|
||||||
|
const renderContent = (inDrawer: boolean) => (
|
||||||
<div className="flex h-full flex-col bg-sidebar">
|
<div className="flex h-full flex-col bg-sidebar">
|
||||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||||
<ul className="flex flex-col gap-0.5">
|
<ul className="flex flex-col gap-0.5">
|
||||||
@@ -145,6 +149,9 @@ export function Sidebar() {
|
|||||||
)}
|
)}
|
||||||
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* Dashboard-Reiter (Runde 3): `DashboardTabs` zeichnet per Portal
|
||||||
|
hierher, der Punkt bleibt im React-Baum der Leiste leer. */}
|
||||||
|
{!inDrawer && !isCollapsed && <div id={SIDEBAR_DASHBOARD_SLOT_ID} />}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||||
@@ -275,7 +282,7 @@ export function Sidebar() {
|
|||||||
: 'var(--sidebar-width)',
|
: 'var(--sidebar-width)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sidebarContent}
|
{renderContent(false)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{isMobileOpen && (
|
{isMobileOpen && (
|
||||||
@@ -307,7 +314,7 @@ export function Sidebar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1">{sidebarContent}</div>
|
<div className="min-h-0 flex-1">{renderContent(true)}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
BACKGROUND_PRESETS,
|
BACKGROUND_PRESETS,
|
||||||
|
DARK_FALLBACK_ID,
|
||||||
loadDashboardBackground,
|
loadDashboardBackground,
|
||||||
presetBackground,
|
presetBackground,
|
||||||
saveDashboardBackground,
|
saveDashboardBackground,
|
||||||
@@ -44,11 +45,13 @@ describe('dashboard-background (Design „Mosaik“, Prototyp)', () => {
|
|||||||
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
expect(loadDashboardBackground('u1')).toEqual({ kind: 'none' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('jeder eingebaute Hintergrund hat eine helle und eine dunkle Fassung', () => {
|
it('jeder eingebaute Hintergrund liefert hell und dunkel ein Bild (dunkel notfalls den Ersatz)', () => {
|
||||||
for (const preset of BACKGROUND_PRESETS) {
|
for (const preset of BACKGROUND_PRESETS) {
|
||||||
expect(presetBackground(preset.id, false)).toBeTruthy();
|
expect(presetBackground(preset.id, false)).toBeTruthy();
|
||||||
expect(presetBackground(preset.id, true)).toBeTruthy();
|
expect(presetBackground(preset.id, true)).toBeTruthy();
|
||||||
expect(presetBackground(preset.id, false)).not.toBe(presetBackground(preset.id, true));
|
expect(presetBackground(preset.id, false)).not.toBe(presetBackground(preset.id, true));
|
||||||
}
|
}
|
||||||
|
// Runde 3: „Bluete“ hat keine dunkle Fassung, dunkel greift der Ersatz.
|
||||||
|
expect(presetBackground('bloom', true)).toBe(presetBackground(DARK_FALLBACK_ID, true));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ export interface BackgroundPreset {
|
|||||||
/** Uebersetzungsschluessel unter `dashboard.background.presets`. */
|
/** Uebersetzungsschluessel unter `dashboard.background.presets`. */
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
light: string;
|
light: string;
|
||||||
dark: string;
|
/**
|
||||||
|
* `null` = im Dunkelmodus nicht angeboten (Runde 3). Wer den Hintergrund
|
||||||
|
* im hellen Modus gewaehlt hat, sieht im dunklen den Ersatz
|
||||||
|
* {@link DARK_FALLBACK_ID}.
|
||||||
|
*/
|
||||||
|
dark: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ersatz im Dunkelmodus fuer Hintergruende ohne dunkle Fassung. */
|
||||||
|
export const DARK_FALLBACK_ID: BackgroundPresetId = 'mist';
|
||||||
|
|
||||||
// Runde 2: nur Gelb-, Grau- und Graphittoene — keine bunten Modulfarben.
|
// Runde 2: nur Gelb-, Grau- und Graphittoene — keine bunten Modulfarben.
|
||||||
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
||||||
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
|
{ id: 'mist', labelKey: 'mist', light: '#e4e9f1', dark: '#1d2330' },
|
||||||
@@ -86,7 +94,10 @@ export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
|||||||
id: 'bloom',
|
id: 'bloom',
|
||||||
labelKey: 'bloom',
|
labelKey: 'bloom',
|
||||||
light: bloom('#eceef1', '#f0e9c6', '#d3d7de', '#dddbd5'),
|
light: bloom('#eceef1', '#f0e9c6', '#d3d7de', '#dddbd5'),
|
||||||
dark: bloom('#1b1d22', '#37342a', '#2a2e35', '#2f2e2a'),
|
// Runde 3: im Dunkelmodus weggelassen — der weichgezeichnete Verlauf
|
||||||
|
// zwischen sehr dunklen Toenen zeigt sichtbare Farbstufen (8 Bit reichen
|
||||||
|
// dort nicht), auch mit Korn. Ersatz: DARK_FALLBACK_ID.
|
||||||
|
dark: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dunes',
|
id: 'dunes',
|
||||||
@@ -105,7 +116,9 @@ export const BACKGROUND_PRESETS: BackgroundPreset[] = [
|
|||||||
export function presetBackground(id: BackgroundPresetId, dark: boolean): string | null {
|
export function presetBackground(id: BackgroundPresetId, dark: boolean): string | null {
|
||||||
const preset = BACKGROUND_PRESETS.find((p) => p.id === id);
|
const preset = BACKGROUND_PRESETS.find((p) => p.id === id);
|
||||||
if (!preset) return null;
|
if (!preset) return null;
|
||||||
return dark ? preset.dark : preset.light;
|
if (!dark) return preset.light;
|
||||||
|
if (preset.dark !== null) return preset.dark;
|
||||||
|
return BACKGROUND_PRESETS.find((p) => p.id === DARK_FALLBACK_ID)?.dark ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageKey(userId: string): string {
|
function storageKey(userId: string): string {
|
||||||
|
|||||||
@@ -327,7 +327,9 @@
|
|||||||
"saveSuccess": "Kalenderquelle gespeichert.",
|
"saveSuccess": "Kalenderquelle gespeichert.",
|
||||||
"saveError": "Die Kalenderquelle konnte nicht gespeichert werden.",
|
"saveError": "Die Kalenderquelle konnte nicht gespeichert werden.",
|
||||||
"deleteDialogCancel": "Löschen abbrechen",
|
"deleteDialogCancel": "Löschen abbrechen",
|
||||||
"deleteDialogLabel": "Löschen bestätigen"
|
"deleteDialogLabel": "Löschen bestätigen",
|
||||||
|
"today": "Heute",
|
||||||
|
"tomorrow": "Morgen"
|
||||||
},
|
},
|
||||||
"note": {
|
"note": {
|
||||||
"name": "Notizen",
|
"name": "Notizen",
|
||||||
|
|||||||
@@ -327,7 +327,9 @@
|
|||||||
"saveSuccess": "Calendar source saved.",
|
"saveSuccess": "Calendar source saved.",
|
||||||
"saveError": "The calendar source could not be saved.",
|
"saveError": "The calendar source could not be saved.",
|
||||||
"deleteDialogCancel": "Cancel deletion",
|
"deleteDialogCancel": "Cancel deletion",
|
||||||
"deleteDialogLabel": "Confirm deletion"
|
"deleteDialogLabel": "Confirm deletion",
|
||||||
|
"today": "Today",
|
||||||
|
"tomorrow": "Tomorrow"
|
||||||
},
|
},
|
||||||
"note": {
|
"note": {
|
||||||
"name": "Notes",
|
"name": "Notes",
|
||||||
|
|||||||
Reference in New Issue
Block a user