intermittent commit
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import type { ApiSettings, AuthInfo, PlatformNavItem } from "../types";
|
||||
import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
|
||||
import IconRail from "./IconRail";
|
||||
import Titlebar from "./Titlebar";
|
||||
import BreadcrumbBar from "./BreadcrumbBar";
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
publicMode?: boolean;
|
||||
navItems?: PlatformNavItem[];
|
||||
maintenanceMode?: { enabled: boolean; message?: string | null };
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { PlatformWebModule } from "../types";
|
||||
import { helpContextForPathname, helpQueryForContext, type HelpContext } from "../utils/helpContext";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
const EXTERNAL_DOCS_BASE_URL = "https://govoplan.add-ideas.de";
|
||||
|
||||
export default function HelpMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
@@ -50,8 +52,11 @@ export default function HelpMenu() {
|
||||
}
|
||||
|
||||
function openDocs(type: "user" | "admin") {
|
||||
if (!docsAvailable) return;
|
||||
setOpen(false);
|
||||
if (!docsAvailable) {
|
||||
window.open(externalDocsUrl(type, helpContext), "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams({ type });
|
||||
params.set("context", helpContext.id);
|
||||
navigate(`/docs?${params.toString()}`);
|
||||
@@ -79,10 +84,10 @@ export default function HelpMenu() {
|
||||
<HelpCircle size={16} /> {translateText("i18n:govoplan-core.help.c47ae153")} <small>i18n:govoplan-core.f1.88bfad9c</small>
|
||||
</button>
|
||||
<hr />
|
||||
<button className="dropdown-item" onClick={() => openDocs("user")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
|
||||
<button className="dropdown-item" onClick={() => openDocs("user")} title={docsAvailable ? translateText("i18n:govoplan-core.open_user_documentation.084af515") : "Open hosted user documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.user_docs.1e38e8d3")}
|
||||
</button>
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin")} disabled={!docsAvailable} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : translateText("i18n:govoplan-core.docs_module_is_not_available.08eb0cd5")}>
|
||||
<button className="dropdown-item" onClick={() => openDocs("admin")} title={docsAvailable ? translateText("i18n:govoplan-core.open_admin_documentation.6adbdae3") : "Open hosted admin documentation"}>
|
||||
<BookOpen size={16} /> {translateText("i18n:govoplan-core.admin_docs.bf504a56")}
|
||||
</button>
|
||||
<hr />
|
||||
@@ -96,6 +101,11 @@ export default function HelpMenu() {
|
||||
|
||||
}
|
||||
|
||||
function externalDocsUrl(type: "user" | "admin", context: HelpContext): string {
|
||||
const params = new URLSearchParams({ type, context: context.id });
|
||||
return `${EXTERNAL_DOCS_BASE_URL}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
function ContextHelpModal({ context, onClose }: {context: HelpContext;onClose: () => void;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
||||
@@ -7,6 +7,7 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||
|
||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
|
||||
|
||||
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
|
||||
return [...navItems].
|
||||
@@ -22,14 +23,11 @@ export default function IconRail({
|
||||
compact = false,
|
||||
auth = null,
|
||||
navItems = []
|
||||
|
||||
|
||||
|
||||
|
||||
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
|
||||
const location = useLocation();
|
||||
const items = visibleNavItems(auth, navItems);
|
||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||
const [expanded, setExpanded] = useState(() => loadRailExpanded());
|
||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const navigate = useGuardedNavigate();
|
||||
@@ -46,15 +44,27 @@ export default function IconRail({
|
||||
});
|
||||
}, [location.hash, location.pathname, location.search, topLevelItems]);
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded((current) => {
|
||||
const next = !current;
|
||||
saveRailExpanded(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleNavClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
navigate(target);
|
||||
}
|
||||
|
||||
const railExpanded = !compact && expanded;
|
||||
|
||||
return (
|
||||
<aside className={`icon-rail ${compact ? "compact" : ""}`}>
|
||||
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
||||
<aside className={`icon-rail ${compact ? "compact" : ""} ${railExpanded ? "expanded" : ""}`}>
|
||||
<div className="icon-rail-header">
|
||||
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
|
||||
</div>
|
||||
|
||||
{!compact &&
|
||||
<>
|
||||
@@ -62,9 +72,11 @@ export default function IconRail({
|
||||
{items.map(({ to, label, icon: Icon }) => {
|
||||
const target = rememberedTargets[to] ?? to;
|
||||
const active = modulePathActive(location.pathname, to);
|
||||
const renderedLabel = translateText(label);
|
||||
return (
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={translateText(label)} onClick={(event) => handleNavClick(event, target)}>
|
||||
{Icon ? <Icon size={20} /> : label.slice(0, 1)}
|
||||
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}>
|
||||
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
||||
<span className="icon-nav-label">{renderedLabel}</span>
|
||||
</NavLink>);
|
||||
|
||||
})}
|
||||
@@ -72,7 +84,17 @@ export default function IconRail({
|
||||
<div className="icon-rail-bottom">
|
||||
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
|
||||
<Settings size={20} />
|
||||
<span className="icon-nav-label">{translateText("i18n:govoplan-core.settings.c7f73bb5")}</span>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-nav-item icon-rail-toggle"
|
||||
aria-label={railExpanded ? "Collapse navigation" : "Expand navigation"}
|
||||
title={railExpanded ? "Collapse navigation" : "Expand navigation"}
|
||||
onClick={toggleExpanded}>
|
||||
{railExpanded ? <PanelLeftClose size={20} aria-hidden="true" /> : <PanelLeftOpen size={20} aria-hidden="true" />}
|
||||
<span className="icon-nav-label">{railExpanded ? "Collapse" : "Expand"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@@ -107,7 +129,23 @@ function saveRememberedTargets(targets: Record<string, string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(MODULE_NAV_STORAGE_KEY, JSON.stringify(targets));
|
||||
} catch {
|
||||
|
||||
|
||||
// Remembered navigation is a convenience only.
|
||||
}}
|
||||
|
||||
function loadRailExpanded(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(RAIL_EXPANDED_STORAGE_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveRailExpanded(expanded: boolean): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(RAIL_EXPANDED_STORAGE_KEY, expanded ? "true" : "false");
|
||||
} catch {
|
||||
// Rail width is a presentation preference only.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, LoginResponse } from "../types";
|
||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { apiFetch, isApiError } from "../api/client";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
|
||||
type NotificationSummary = {
|
||||
unread: number;
|
||||
show_unread_badge?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onSettingsChange: (settings: ApiSettings) => void;
|
||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
||||
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
maintenanceMode?: {enabled: boolean;message?: string | null;};
|
||||
};
|
||||
|
||||
@@ -26,9 +33,11 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||
const accountRef = useRef<HTMLDivElement>(null);
|
||||
const tenantRef = useRef<HTMLDivElement>(null);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const modules = usePlatformModules();
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
@@ -41,6 +50,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
"system:tenants:suspend"]
|
||||
);
|
||||
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
|
||||
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
|
||||
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
@@ -56,6 +67,41 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth || !notificationsAvailable) {
|
||||
setUnreadNotificationCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
async function refreshNotificationSummary() {
|
||||
try {
|
||||
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
|
||||
if (!cancelled) {
|
||||
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setUnreadNotificationCount(0);
|
||||
}
|
||||
if (!isApiError(error, 401, 403, 404)) {
|
||||
console.error("Failed to load notification summary", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void refreshNotificationSummary();
|
||||
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
|
||||
window.addEventListener("focus", refreshNotificationSummary);
|
||||
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", refreshNotificationSummary);
|
||||
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
|
||||
};
|
||||
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function handleLogin(response: LoginResponse) {
|
||||
onAuthChange(response, "");
|
||||
}
|
||||
@@ -109,6 +155,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
navigate("/admin?section=system-settings");
|
||||
}
|
||||
|
||||
function openNotificationCenter() {
|
||||
navigate("/notifications");
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="titlebar">
|
||||
{maintenanceMode?.enabled &&
|
||||
@@ -159,8 +209,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
<HelpMenu />
|
||||
<LanguageMenu />
|
||||
<HelpMenu />
|
||||
|
||||
{auth && notificationsAvailable &&
|
||||
<button className="titlebar-icon-link titlebar-notification-button" onClick={openNotificationCenter} title={translateText("i18n:govoplan-core.notifications.753a22b2")} aria-label={translateText("i18n:govoplan-core.notifications.753a22b2")}>
|
||||
<Bell size={18} />
|
||||
{unreadNotificationCount > 0 &&
|
||||
<span className="titlebar-notification-badge" aria-label={`${unreadNotificationCount} unread`}>
|
||||
{notificationBadgeLabel}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<div className="context-menu-wrap" ref={accountRef}>
|
||||
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}>
|
||||
|
||||
Reference in New Issue
Block a user