From 7416a925a67e330e6e32780b18a5048c3365d648 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 24 Sep 2026 12:38:08 +0200 Subject: [PATCH] feat(260924-h7x): Dashboard-Reiter als Umschalter in der Kopfzeile - Kopfzeile bekommt Einhaengepunkt header-center-slot; auf / entfaellt der Text Startseite - DashboardTabs rendert per createPortal dorthin, eigene Zeile ueber dem Raster entfaellt - eingelassene Spur mit erhabenem aktivem Reiter, waagrecht scrollbar mit weicher Randausblendung - role=tablist/tab, Pfeiltasten/Pos1/Ende, sichtbarer Fokusring, aktiver Reiter wird ins Bild gescrollt - Plus-Knopf ausserhalb der Spur, Loeschdialog am Dokumentkoerper, Ziehhinweis als sr-only/Tooltip Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + apps/web/src/app/(portal)/page.tsx | 7 +- .../dashboard/dashboard-tabs.test.tsx | 125 ++++- .../components/dashboard/dashboard-tabs.tsx | 495 ++++++++++++------ apps/web/src/components/layout/header-slot.ts | 6 + .../web/src/components/layout/header.test.tsx | 28 + apps/web/src/components/layout/header.tsx | 23 +- 7 files changed, 487 insertions(+), 201 deletions(-) create mode 100644 apps/web/src/components/layout/header-slot.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cffcf32..68e2d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T - Favoriten-Widget: eigenes Symbol je Link hochladen – PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB – beim Hinzufügen und im Bearbeitungsformular; ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; „Hochgeladenes Symbol entfernen“ macht es rückgängig - Proxmox-Seite neu gestaltet: ein farbiger Balken oben zeigt auf einen Blick, wie viele Server in Ordnung, mit Warnung, nicht erreichbar, noch nicht abgefragt oder offline sind; jede Karte trägt ihren Zustand in Farbe und als Wort, auffällige Server stehen vorn; Auslastung als Balken mit Prozentzahl (ab 80 % Warnung), letzte Sicherung und letzte Abfrage als „vor 5 Std.“; ein deaktivierter Server erscheint als „Offline & verwaist“ ohne veraltete Messwerte +### Geändert + +- Dashboard: die Reiter sitzen jetzt als kompakter Umschalter in der Mitte der Kopfzeile statt in einer eigenen Zeile über den Kacheln – das Dashboard gewinnt dadurch Platz nach oben; viele Reiter lassen sich waagrecht durchblättern, mit den Pfeiltasten wechseln Sie zwischen ihnen; Anlegen, Umbenennen, Löschen und Umsortieren per Ziehen funktionieren wie bisher + ### Behoben - Fehler melden: das Häkchen „Bildschirmfoto beifügen“ war in manchen Fällen gesperrt, weil die Aufnahme an einem einzelnen Bild einer fremden Website scheiterte; die Aufnahme gelingt jetzt trotzdem diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index edac819..6c115ec 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -114,9 +114,10 @@ export default function DashboardPage() { return (
- {/* Reiterleiste (quick-260923-ad9) — bleibt waehrend eines - Reiterwechsels stehen, nur das Raster darunter zeigt eine kurze - Ladezeile. */} + {/* Reiterleiste (quick-260923-ad9) — seit quick-260924-h7x per Portal + in der Kopfzeile, hier nimmt sie keinen Platz mehr ein. Sie bleibt + waehrend eines Reiterwechsels stehen, nur das Raster zeigt eine + kurze Ladezeile. */} [0]> = }; } +// Seit quick-260924-h7x rendert die Leiste per Portal in den Einhaengepunkt +// der Kopfzeile — der Test legt ihn so an, wie `header.tsx` ihn rendert. +beforeEach(() => { + const slot = document.createElement('div'); + slot.id = 'header-center-slot'; + document.body.appendChild(slot); +}); + afterEach(() => { cleanup(); + document.getElementById('header-center-slot')?.remove(); vi.restoreAllMocks(); }); @@ -99,24 +108,27 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { it('Test 1: rendert jeden Reiter mit seinem Namen', () => { render(); - expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Dashboard 2' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toBeInTheDocument(); }); it('Test 2: ein Klick auf einen Reiter ruft onSelect mit dessen Kennung auf', () => { const onSelect = vi.fn(); render(); - fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' })); + fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' })); expect(onSelect).toHaveBeenCalledWith('d2'); }); - it('Test 3: der aktive Reiter traegt aria-current, der andere nicht', () => { + it('Test 3: der aktive Reiter traegt aria-selected=true, der andere false', () => { render(); - expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true'); - expect(screen.getByRole('button', { name: 'Dashboard 2' })).not.toHaveAttribute('aria-current'); + expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Dashboard 2' })).toHaveAttribute( + 'aria-selected', + 'false', + ); }); it('Test 4: ausserhalb des Bearbeitungsmodus gibt es weder Umbenennen- noch Loeschen- noch Hinzufuegen-Knoepfe', () => { @@ -171,7 +183,7 @@ describe('DashboardTabs (quick-260923-ad9, Task 3)', () => { fireEvent.keyDown(input, { key: 'Escape' }); expect(onRename).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument(); }); it('Test 10: der Umbenennen-Knopf steht nur beim AKTIVEN Reiter, nicht bei den anderen', () => { @@ -219,7 +231,7 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', expect(onReorder).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'Dashboard 2' })); + fireEvent.click(screen.getByRole('tab', { name: 'Dashboard 2' })); expect(onSelect).toHaveBeenCalledWith('d2'); }); @@ -261,14 +273,16 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); // Vorschau: d2 steht jetzt vor d1 im DOM. - const buttons = screen.getAllByRole('button').filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); + const buttons = screen + .getAllByRole('tab') + .filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); expect(buttons.map((b) => b.textContent)).toEqual(['Dashboard 2', 'Dashboard']); fireEvent.pointerCancel(tab, { pointerId: 1 }); expect(onReorder).not.toHaveBeenCalled(); const buttonsAfterCancel = screen - .getAllByRole('button') + .getAllByRole('tab') .filter((b) => b.textContent === 'Dashboard' || b.textContent === 'Dashboard 2'); expect(buttonsAfterCancel.map((b) => b.textContent)).toEqual(['Dashboard', 'Dashboard 2']); }); @@ -289,13 +303,17 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', it('Test 18: der aktive Reiter bleibt beim Ziehen aktiv, auch wenn er seine Position wechselt', () => { stubTabRects(); - render(); + render( + , + ); const tab = screen.getByTestId('dashboard-tab-d1'); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); fireEvent.pointerMove(tab, { clientX: 180, pointerId: 1 }); - expect(screen.getByRole('button', { name: 'Dashboard' })).toHaveAttribute('aria-current', 'true'); + expect(screen.getByRole('tab', { name: 'Dashboard' })).toHaveAttribute('aria-selected', 'true'); }); it('Test 19: nach einem echten Ziehen unterdrueckt der naechste Klick die Auswahl EINMAL, danach funktioniert Klicken wieder normal', () => { @@ -311,16 +329,18 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', expect(onReorder).toHaveBeenCalledTimes(1); // Ein echter Browser wuerde nach dem Ziehen noch einen "click" nachreichen. - fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })); + fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' })); expect(onSelect).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })); + fireEvent.click(screen.getByRole('tab', { name: 'Dashboard' })); expect(onSelect).toHaveBeenCalledWith('d1'); }); it('Test 20: der Hinweistext zum Ziehen steht bei mehr als einem Reiter, aber nicht bei genau einem', () => { const { rerender } = render(); - expect(screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.')).toBeInTheDocument(); + expect( + screen.getByText('Ziehen Sie einen Reiter, um ihn zum Standard zu machen.'), + ).toBeInTheDocument(); rerender(); expect( @@ -331,7 +351,9 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', it('Test 21: Ziehen ist auch AUSSERHALB des Bearbeitungsmodus moeglich (D-09)', () => { stubTabRects(); const onReorder = vi.fn(); - render(); + render( + , + ); const tab = screen.getByTestId('dashboard-tab-d1'); fireEvent.pointerDown(tab, { clientX: 10, pointerId: 1, button: 0 }); @@ -341,3 +363,72 @@ describe('DashboardTabs — Ziehen zum Umsortieren (quick-260923-ad9, Task 4)', expect(onReorder).toHaveBeenCalledWith(['d2', 'd1']); }); }); + +describe('DashboardTabs — in der Kopfzeile (quick-260924-h7x)', () => { + it('Test 22: rendert in den Einhaengepunkt der Kopfzeile, nicht an Ort und Stelle', () => { + const { container } = render(); + + const slot = document.getElementById('header-center-slot') as HTMLElement; + expect(within(slot).getByRole('navigation', { name: 'Dashboard-Reiter' })).toBeInTheDocument(); + expect(within(slot).getByRole('tablist')).toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); + + it('Test 23: ohne Einhaengepunkt rendert die Leiste gar nichts', () => { + document.getElementById('header-center-slot')?.remove(); + render(); + + expect(screen.queryByRole('tablist')).not.toBeInTheDocument(); + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); + }); + + it('Test 24: Pfeiltasten wandern zwischen den Reitern (mit Umlauf), ohne zu waehlen; nur der aktive ist per Tab erreichbar', () => { + const onSelect = vi.fn(); + render(); + const [t1, t2, t3] = screen.getAllByRole('tab'); + + expect(t1).toHaveAttribute('tabindex', '0'); + expect(t2).toHaveAttribute('tabindex', '-1'); + expect(t3).toHaveAttribute('tabindex', '-1'); + + t1.focus(); + fireEvent.keyDown(t1, { key: 'ArrowRight' }); + expect(t2).toHaveFocus(); + fireEvent.keyDown(t2, { key: 'ArrowRight' }); + expect(t3).toHaveFocus(); + fireEvent.keyDown(t3, { key: 'ArrowRight' }); + expect(t1).toHaveFocus(); + fireEvent.keyDown(t1, { key: 'ArrowLeft' }); + expect(t3).toHaveFocus(); + fireEvent.keyDown(t3, { key: 'Home' }); + expect(t1).toHaveFocus(); + fireEvent.keyDown(t1, { key: 'End' }); + expect(t3).toHaveFocus(); + + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('Test 25: genau ein Reiter — der Umschalter steht trotzdem, im Bearbeitungsmodus nur mit „+“ daneben', () => { + render(); + + expect(screen.getByRole('tab', { name: 'Dashboard' })).toBeInTheDocument(); + expect(screen.getByLabelText('Dashboard hinzufügen')).toBeInTheDocument(); + expect(screen.queryByLabelText('Dashboard löschen')).not.toBeInTheDocument(); + }); + + it('Test 26: „+“ sitzt ausserhalb der scrollenden Spur und bleibt so immer sichtbar', () => { + render(); + + const tablist = screen.getByRole('tablist'); + expect(tablist).not.toContainElement(screen.getByLabelText('Dashboard hinzufügen')); + }); + + it('Test 27: der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile', () => { + render(); + + fireEvent.click(screen.getAllByLabelText('Dashboard löschen')[0]); + const dialog = screen.getByRole('alertdialog'); + const slot = document.getElementById('header-center-slot') as HTMLElement; + expect(slot).not.toContainElement(dialog); + }); +}); diff --git a/apps/web/src/components/dashboard/dashboard-tabs.tsx b/apps/web/src/components/dashboard/dashboard-tabs.tsx index 8cc21ff..bdbde39 100644 --- a/apps/web/src/components/dashboard/dashboard-tabs.tsx +++ b/apps/web/src/components/dashboard/dashboard-tabs.tsx @@ -1,7 +1,18 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { type PointerEvent, useEffect, useRef, useState } from 'react'; +import { + type CSSProperties, + type KeyboardEvent, + type PointerEvent, + useCallback, + useEffect, + useId, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import type { DashboardTab } from '@/lib/dashboard-api'; interface DashboardTabsProps { @@ -15,6 +26,9 @@ interface DashboardTabsProps { onReorder: (ids: string[]) => void; } +/** Breite der weichen Ausblendung an einem ueberlaufenden Rand. */ +const FADE_PX = 16; + /** Ab dieser waagerechten Auslenkung (Bildschirmpixel) wird aus einem Klick ein Ziehen (Task 4). */ const DRAG_THRESHOLD_PX = 4; @@ -63,7 +77,17 @@ function computeReorderedIds( } /** - * Reiterleiste über dem Dashboard-Raster (quick-260923-ad9, Task 3/4). + * Dashboard-Reiter (quick-260923-ad9, Task 3/4; seit quick-260924-h7x in der + * Kopfzeile). + * + * Die Leiste belegt keine eigene Zeile mehr: sie rendert per `createPortal` + * in den Einhaengepunkt {@link HEADER_CENTER_SLOT_ID} der Kopfzeile, sobald + * er im DOM ist — vorher rendert sie nichts. Gestaltung als kompakter + * Umschalter: eingelassene Spur, der aktive Reiter liegt erhaben darauf. + * Laufen die Reiter ueber, scrollt die Spur waagrecht ohne sichtbare Leiste + * und blendet die Raender weich aus; der aktive Reiter wird ins Bild + * gescrollt. Pfeiltasten links/rechts wandern zwischen den Reitern + * (`role="tablist"`/`tab`), Eingabe oder Leertaste waehlt. * * Klick wechselt IMMER den Reiter, unabhängig vom Bearbeitungsmodus. * Umbenennen (an Ort und Stelle, Eingabetaste übernimmt, Escape verwirft) @@ -103,6 +127,61 @@ export function DashboardTabs({ const dragRef = useRef(null); const hasDraggedRef = useRef(false); const tabRefs = useRef>(new Map()); + const tabButtonRefs = useRef>(new Map()); + const scrollRef = useRef(null); + const hintId = useId(); + const [slot, setSlot] = useState(null); + const [overflow, setOverflow] = useState({ left: false, right: false }); + + // Einhaengepunkt der Kopfzeile suchen. Die Kopfzeile steht im selben + // Seitenrahmen (AppShell) und ist nach dem ersten Zeichnen im DOM. + useEffect(() => { + setSlot(document.getElementById(HEADER_CENTER_SLOT_ID)); + }, []); + + /** Misst, ob die Spur links/rechts ueberlaeuft — steuert die weiche Ausblendung. */ + const measureOverflow = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const left = el.scrollLeft > 1; + const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; + setOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right })); + }, []); + + // `slot` ist der Ausloeser: die Spur (scrollRef) existiert erst, wenn das + // Portal in die Kopfzeile gezeichnet ist. + // biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + measureOverflow(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(measureOverflow); + observer.observe(el); + return () => observer.disconnect(); + }, [measureOverflow, slot]); + + // Nach jeder Aenderung der Reiter neu messen (neuer Reiter, umbenannt). + // biome-ignore lint/correctness/useExhaustiveDependencies: `dashboards` ist der Ausloeser, nicht Eingabe + useEffect(() => { + measureOverflow(); + }, [dashboards, measureOverflow]); + + // Aktiven Reiter ins Bild scrollen — nur die Spur, nie die Seite. + // biome-ignore lint/correctness/useExhaustiveDependencies: `slot` ist der Ausloeser, nicht Eingabe + useEffect(() => { + const container = scrollRef.current; + const tab = activeDashboardId ? tabRefs.current.get(activeDashboardId) : undefined; + if (!container || !tab) return; + const c = container.getBoundingClientRect(); + const r = tab.getBoundingClientRect(); + if (r.left < c.left) { + container.scrollLeft -= c.left - r.left + FADE_PX; + } else if (r.right > c.right) { + container.scrollLeft += r.right - c.right + FADE_PX; + } + measureOverflow(); + }, [activeDashboardId, measureOverflow, slot]); // Fokus auf das Eingabefeld beim Wechsel in den Umbenennen-Zustand — ueber // einen Ref statt des autoFocus-Attributs (lint/a11y/noAutofocus), Muster @@ -204,190 +283,258 @@ export function DashboardTabs({ setRenamingId(null); } + /** Pfeiltasten wandern zwischen den Reitern; gewaehlt wird mit Eingabe/Leertaste. */ + function handleTabKeyDown(e: KeyboardEvent, index: number) { + const count = orderedTabs.length; + let next: number; + if (e.key === 'ArrowRight') next = (index + 1) % count; + else if (e.key === 'ArrowLeft') next = (index - 1 + count) % count; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = count - 1; + else return; + e.preventDefault(); + tabButtonRefs.current.get(orderedTabs[next].id)?.focus(); + } + const pendingDeleteTab = dashboards.find((d) => d.id === pendingDeleteId) ?? null; - return ( -
-
+ ); + })} +
+ + + {/* „+“ sitzt AUSSERHALB der scrollenden Spur und bleibt deshalb auch + bei vielen Reitern immer sichtbar — wie bisher nur im + Bearbeitungsmodus. */} + {isEditMode && ( + + )} + + {/* Hinweis zum Ziehen: frueher eine eigene Zeile unter der Leiste, jetzt + fuer Vorleseprogramme an der Spur und als Tooltip der Leiste. */} + {dashboards.length > 1 && ( + + {t('tabs.dragHint')} + + )} + + ); + + // Der Loeschdialog haengt am Dokumentkoerper, nicht in der Kopfzeile — + // sonst saesse er in deren Stapelkontext. + const dialog = pendingDeleteTab + ? createPortal( +
- )} - - {pendingDeleteTab && ( -
- - -
+

+ {t('tabs.deleteDialogTitle')} +

+

+ {t('tabs.deleteDialogBody', { name: pendingDeleteTab.name })} +

+
+ +
- )} - - {dashboards.length > 1 && ( -

{t('tabs.dragHint')}

- )} - + , + document.body, + ) + : null; + + return ( + <> + {createPortal(bar, slot)} + {dialog} + ); } diff --git a/apps/web/src/components/layout/header-slot.ts b/apps/web/src/components/layout/header-slot.ts new file mode 100644 index 0000000..fa33574 --- /dev/null +++ b/apps/web/src/components/layout/header-slot.ts @@ -0,0 +1,6 @@ +/** + * Kennung des Einhaengepunkts in der Mitte der Kopfzeile (quick-260924-h7x). + * Die Kopfzeile rendert ein leeres Element mit dieser Kennung; Seiten + * zeichnen per `createPortal` hinein (heute: die Dashboard-Reiter). + */ +export const HEADER_CENTER_SLOT_ID = 'header-center-slot'; diff --git a/apps/web/src/components/layout/header.test.tsx b/apps/web/src/components/layout/header.test.tsx index 298164d..675d951 100644 --- a/apps/web/src/components/layout/header.test.tsx +++ b/apps/web/src/components/layout/header.test.tsx @@ -150,3 +150,31 @@ describe('Header — Sitzungswaechter (quick-260917-gyd)', () => { ).toHaveTextContent('?'); }); }); + +describe('Header — Mitte der Kopfzeile (quick-260924-h7x)', () => { + it('auf der Startseite: kein Text „Startseite“, aber der leere Einhaengepunkt fuer die Reiter', async () => { + mockPathname = '/'; + vi.stubGlobal('location', { href: '', pathname: '/', search: '' }); + fetchSessionState.mockResolvedValue({ status: 'unavailable' }); + + await renderHeader(); + + expect(screen.queryByText('Startseite')).not.toBeInTheDocument(); + const slot = document.getElementById('header-center-slot'); + expect(slot).not.toBeNull(); + expect(slot).toBeEmptyDOMElement(); + await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); + }); + + it('auf anderen Seiten: „Startseite“ bleibt wie bisher', async () => { + mockPathname = '/modules/proxmox'; + vi.stubGlobal('location', { href: '', pathname: '/modules/proxmox', search: '' }); + fetchSessionState.mockResolvedValue({ status: 'unavailable' }); + + await renderHeader(); + + expect(screen.getByText('Startseite')).toBeInTheDocument(); + expect(document.getElementById('header-center-slot')).not.toBeNull(); + await waitFor(() => expect(fetchSessionState).toHaveBeenCalled()); + }); +}); diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 9ef1a04..5140c47 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -10,6 +10,7 @@ import { fetchSessionState, logout } from '@/lib/auth-actions'; import { buildNextParam } from '@/lib/safe-next'; import { ThemeToggle } from '@/components/theme-toggle'; import { BugReportButton } from '@/components/bug-report/bug-report-button'; +import { HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot'; import { usePathname } from 'next/navigation'; export function Header() { @@ -97,7 +98,7 @@ export function Header() { return (
{/* Left: Logo + Mobile hamburger */} -
+
- {/* Center: Breadcrumb / page title */} -
- - {tHeader('breadcrumb.home')} - + {/* Mitte: Einhaengepunkt fuer die Dashboard-Reiter (quick-260924-h7x). + `DashboardTabs` rendert per Portal hierher; der Punkt muss deshalb + im React-Baum der Kopfzeile LEER bleiben. Auf der Startseite + entfaellt der Text „Startseite“, auf allen anderen Seiten bleibt + er wie bisher. `min-w-0` laesst die Mitte schrumpfen, damit Logo + und Aktionen rechts auch bei schmalem Fenster Platz behalten. */} +
+
+ {pathname !== '/' && ( + + {tHeader('breadcrumb.home')} + + )}
{/* Right: Actions */} -
+