bdaa3c8db5
Der Knopf Bearbeiten belegte eine eigene Zeile ueber den Widgets. Die Aktionen rendern jetzt per Portal in einen Einhaengepunkt rechts in der Kopfzeile (HEADER_ACTIONS_SLOT_ID); in der Ansicht nur der Stift, im Bearbeitungsmodus Hintergrund, Widget hinzufuegen und Fertig. Version 1.5.1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
306 lines
13 KiB
TypeScript
306 lines
13 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_ACTIONS_SLOT_ID, HEADER_CENTER_SLOT_ID } from '@/components/layout/header-slot';
|
|
import { usePathname } from 'next/navigation';
|
|
import { resolvePageTitle, useNavStore } from '@/lib/stores/nav-store';
|
|
|
|
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,
|
|
dashboardBackground: u.dashboardBackground ?? null,
|
|
});
|
|
} 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}`) : '';
|
|
|
|
// Seitentitel in der Mitte (Design „Mosaik“): statt immer „Startseite“
|
|
// der Name der aktuellen Seite bzw. des Moduls.
|
|
const navModules = useNavStore((s) => s.modules);
|
|
const pageTitle = resolvePageTitle(pathname, navModules);
|
|
const pageTitleText = pageTitle
|
|
? 'key' in pageTitle
|
|
? t(pageTitle.key)
|
|
: pageTitle.text
|
|
: null;
|
|
|
|
return (
|
|
<header className="sticky top-0 z-50 flex h-[var(--header-height)] items-center bg-appbar px-2 text-appbar-foreground sm:px-3 dark:shadow-[inset_0_-1px_0_oklch(1_0_0/0.07)]">
|
|
{/* Links: Menue (mobil) + Bildmarke. Dunkle App-Leiste wie bei GitHub
|
|
bzw. Microsoft 365 — in beiden Erscheinungsbildern dunkel. */}
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMobileOpen(true)}
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted hover:bg-appbar-hover hover:text-appbar-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>
|
|
<Link
|
|
href="/"
|
|
className="flex items-center rounded-md px-1 py-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-appbar-foreground"
|
|
>
|
|
<TesseraLogo
|
|
variant="horizontal"
|
|
size={26}
|
|
plateOutline="always"
|
|
className="gap-2.5"
|
|
// Schriftzug weiss auf der dunklen Leiste (in beiden
|
|
// Erscheinungsbildern). Unter 640 px nur die Bildmarke: der Platz
|
|
// gehoert dort den Dashboard-Reitern (260924-h7x).
|
|
wordmarkClassName="hidden sm:inline text-[15px] font-semibold tracking-normal text-appbar-foreground"
|
|
/>
|
|
</Link>
|
|
</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" />
|
|
{pageTitleText !== null && (
|
|
<span className="truncate text-sm font-semibold text-appbar-foreground">
|
|
{pageTitleText}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Actions */}
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{/* Einhaengepunkt fuer die Dashboard-Aktionen (quick-260928-vxe);
|
|
bleibt im React-Baum der Kopfzeile leer, `empty:hidden` nimmt ihm
|
|
auf anderen Seiten den Abstand. */}
|
|
<div id={HEADER_ACTIONS_SLOT_ID} className="flex items-center gap-1 empty:hidden" />
|
|
<BugReportButton />
|
|
<ThemeToggle />
|
|
|
|
{/* User avatar dropdown */}
|
|
<div className="relative" ref={dropdownRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
className="ml-1 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-primary text-xs font-semibold text-primary-foreground ring-1 ring-white/15 transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-appbar-foreground"
|
|
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 z-50 mt-2 w-60 rounded-lg bg-card dark:border dark:border-border py-1.5 text-card-foreground shadow-lg">
|
|
{/* 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>
|
|
);
|
|
}
|