Files
tessera-ctl/apps/web/src/components/layout/header.tsx
T
schalli 7416a925a6 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) <noreply@anthropic.com>
2026-09-24 12:38:08 +02:00

283 lines
12 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useAuthStore } from '@/lib/stores/auth-store';
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() {
const t = useTranslations();
const tHeader = useTranslations('header');
const setMobileOpen = useSidebarStore((s) => s.setMobileOpen);
const { user, setUser, avatarVersion } = useAuthStore();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
// Redirect-Sperre gegen doppelte Navigation (StrictMode-Doppeleffekt,
// Effekt-Wiederholungen) — quick-260917-gyd.
const redirectedRef = useRef(false);
// Sitzungswaechter (quick-260917-gyd): Der Header ist der EINZIGE
// Waechter, weil er auf jeder Portalseite genau einmal gerendert wird
// (AppShell im (portal)-Layout); das (auth)-Layout hat keinen Header,
// /login ist oeffentlich (middleware.ts publicRoutes) — Seitenleiste
// und Widget-Aufrufe brauchen deshalb keinen eigenen Umbau, ein
// Doppel-Redirect ist ausgeschlossen. `authenticated` fuellt den Store,
// `unauthenticated` (tote Sitzung, Cookie bereits serverseitig
// geloescht) leitet per Vollnavigation zur Anmeldeseite mit `next` auf
// die aktuelle Seite, `unavailable` (API-Ausfall) bleibt still.
useEffect(() => {
if (user) {
return;
}
fetchSessionState().then((state) => {
if (state.status === 'authenticated') {
const u = state.user;
setUser({
id: u.id,
username: u.username,
displayName: u.displayName,
role: u.role,
tenantId: u.tenantId,
hasAvatar: u.hasAvatar,
accentColor: u.accentColor,
});
} else if (state.status === 'unauthenticated') {
if (redirectedRef.current) {
return;
}
redirectedRef.current = true;
const next = buildNextParam(
window.location.pathname,
window.location.search,
);
window.location.href = next
? `/login?next=${encodeURIComponent(next)}`
: '/login';
}
// 'unavailable' (T-gyd-02): kein Redirect, bisheriges stilles
// Verhalten -- ein API-Ausfall darf keine Abmelde-Schleife ausloesen.
});
}, [user, setUser]);
// Close dropdown on outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [dropdownOpen]);
const [avatarError, setAvatarError] = useState(false);
const userInitial = user
? (user.displayName ?? user.username).charAt(0).toUpperCase()
: '?';
const roleLabel = user ? tHeader(`role.${user.role}`) : '';
return (
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center border-b border-border bg-background px-4 shadow-sm">
{/* Left: Logo + Mobile hamburger */}
<div className="flex shrink-0 items-center gap-3">
<button
type="button"
onClick={() => setMobileOpen(true)}
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors md:hidden"
aria-label={t('common.menu')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<TesseraLogo
variant="horizontal"
size={28}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
wordmarkClassName="dark:text-primary"
/>
</div>
{/* 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. */}
<div className="flex min-w-0 flex-1 items-center justify-center px-2 text-center md:px-4">
<div id={HEADER_CENTER_SLOT_ID} className="flex min-w-0 max-w-full justify-center" />
{pathname !== '/' && (
<span className="text-sm text-muted-foreground truncate">
{tHeader('breadcrumb.home')}
</span>
)}
</div>
{/* Right: Actions */}
<div className="flex shrink-0 items-center gap-2">
<BugReportButton />
<ThemeToggle />
{/* User avatar dropdown */}
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-8 w-8 rounded-full overflow-hidden bg-primary flex items-center justify-center text-xs font-medium text-primary-foreground hover:opacity-90 transition-opacity"
aria-label={tHeader('userMenu')}
>
{user?.hasAvatar && !avatarError ? (
<img
src={`/api-proxy/users/me/avatar?v=${avatarVersion}`}
alt=""
aria-hidden="true"
className="h-8 w-8 rounded-full object-cover"
onError={() => setAvatarError(true)}
/>
) : (
<span>{userInitial}</span>
)}
</button>
{/* Dropdown menu */}
{dropdownOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-md border border-border bg-card shadow-lg py-2 z-50">
{/* User info */}
<div className="px-4 py-2 border-b border-border">
<p className="text-sm font-medium text-foreground truncate">
{user?.displayName ?? user?.username ?? '...'}
</p>
{user && (
<span
className={`inline-block mt-1 rounded-full px-2 py-0.5 text-xs font-medium ${
user.role === 'SUPER_ADMIN'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: user.role === 'ADMIN'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{roleLabel}
</span>
)}
</div>
{/* Settings link */}
<div className="px-2 py-1">
<Link
href="/settings"
onClick={() => setDropdownOpen(false)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
{tHeader('settings')}
</Link>
</div>
{/* Administration link */}
{isAdmin && (
<div className="px-2 py-1">
<Link
href="/admin/users"
onClick={() => setDropdownOpen(false)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${
pathname.startsWith('/admin')
? 'bg-muted text-foreground font-medium'
: 'text-foreground hover:bg-muted'
}`}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
{tHeader('admin.label')}
</Link>
</div>
)}
{/* Logout button */}
<div className="border-t border-border my-1" />
<div className="px-2 py-1">
<button
type="button"
onClick={() => {
setDropdownOpen(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
{tHeader('logout')}
</button>
</div>
</div>
)}
</div>
</div>
</header>
);
}