refactor(AppShell): pure renderer, drop browser fetch
The browser-fetching variant ran into the cross-origin / cert-trust wall that came up the moment a real user loaded the page. Move the network call out of the library: <AppShell> now takes a pre-resolved ShellConfig as a required prop. Apps fetch from gsc-shell-api in their RSC layout (server-to-server, no CORS, no cert dance) and pass the result down. Public surface: AppShell, useShell, ShellConfig + sub-types Removed: ShellProvider (network logic gone), all fetcher props (apiUrl, getToken, appKey, initialConfig, revalidateMs). useShell() now returns ShellConfig directly (was ShellContextValue with loading/error/refresh — no longer relevant). Apps drive revalidation by re-rendering; chrome stays request-fresh without any client-side polling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
package-lock.json
generated
25
package-lock.json
generated
@@ -19,8 +19,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/google.maps": "^3.55.0",
|
"@types/google.maps": "^3.55.0",
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -453,30 +453,23 @@
|
|||||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
|
||||||
"version": "15.7.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-dom": {
|
"node_modules/@types/react-dom": {
|
||||||
"version": "18.3.7",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-transition-group": {
|
"node_modules/@types/react-transition-group": {
|
||||||
|
|||||||
@@ -55,8 +55,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/google.maps": "^3.55.0",
|
"@types/google.maps": "^3.55.0",
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"typescript": "^5.4.0"
|
"typescript": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type PopoverProps = {
|
|||||||
title?: React.ReactNode;
|
title?: React.ReactNode;
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
children: React.ReactElement;
|
children: React.ReactElement<any>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useFloating, offset, shift, flip, arrow, Placement, Middleware } from '
|
|||||||
export type TooltipProps = {
|
export type TooltipProps = {
|
||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
children: React.ReactElement;
|
children: React.ReactElement<any>;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import React, { createContext, useContext } from "react";
|
||||||
|
|
||||||
import { Navbar } from "./Navbar";
|
import { Navbar } from "./Navbar";
|
||||||
import { Sidebar, type SidebarNavItem } from "./Sidebar";
|
import { Sidebar, type SidebarNavItem } from "./Sidebar";
|
||||||
import { Footer, type FooterNavItem } from "./Footer";
|
import { Footer, type FooterNavItem } from "./Footer";
|
||||||
import { PageShell } from "./PageShell";
|
import { PageShell } from "./PageShell";
|
||||||
|
|
||||||
// ─── ShellConfig types — mirror gsc-shell-api's response DTO ──────────────────
|
// ─── ShellConfig types ────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Mirrors gsc-shell-api's response DTO. Apps fetch this server-side (RSC)
|
||||||
|
// and pass it to <AppShell>; the library doesn't talk to the network.
|
||||||
|
|
||||||
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
|
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
|
||||||
|
|
||||||
@@ -52,112 +55,28 @@ export type ShellConfig = {
|
|||||||
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
|
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
// ─── Context — exposes config to descendants via useShell() ───────────────────
|
||||||
|
|
||||||
type ShellContextValue = {
|
const ShellContext = createContext<ShellConfig | null>(null);
|
||||||
config: ShellConfig | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
refresh: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ShellContext = createContext<ShellContextValue | null>(null);
|
/** Read the current ShellConfig from anywhere inside <AppShell>. */
|
||||||
|
export function useShell(): ShellConfig {
|
||||||
export function useShell(): ShellContextValue {
|
const cfg = useContext(ShellContext);
|
||||||
const ctx = useContext(ShellContext);
|
if (!cfg) {
|
||||||
if (!ctx) {
|
throw new Error("useShell must be used inside <AppShell>");
|
||||||
throw new Error("useShell must be used inside <AppShell> / <ShellProvider>");
|
|
||||||
}
|
}
|
||||||
return ctx;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Provider — handles fetch + revalidation + cache ──────────────────────────
|
// ─── AppShell — pure renderer ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ShellProviderProps = {
|
|
||||||
/** App identifier as registered in shell-api (e.g. "gsc-crm") */
|
|
||||||
appKey: string;
|
|
||||||
/** Base URL of gsc-shell-api (e.g. "https://shell-api.gosec.internal") */
|
|
||||||
apiUrl: string;
|
|
||||||
/** Returns a fresh Keycloak access token. Called on each fetch. */
|
|
||||||
getToken: () => Promise<string>;
|
|
||||||
/** Optional: a snapshot of ShellConfig to render before the first fetch finishes. */
|
|
||||||
initialConfig?: ShellConfig;
|
|
||||||
/** Optional: revalidate this often (ms). Default 5 minutes. */
|
|
||||||
revalidateMs?: number;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ShellProvider({
|
|
||||||
appKey,
|
|
||||||
apiUrl,
|
|
||||||
getToken,
|
|
||||||
initialConfig,
|
|
||||||
revalidateMs = 5 * 60 * 1000,
|
|
||||||
children,
|
|
||||||
}: ShellProviderProps) {
|
|
||||||
const [config, setConfig] = useState<ShellConfig | null>(initialConfig ?? null);
|
|
||||||
const [loading, setLoading] = useState<boolean>(!initialConfig);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [etag, setEtag] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetcher = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const token = await getToken();
|
|
||||||
const res = await fetch(`${apiUrl}/api/v1/shell/${encodeURIComponent(appKey)}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : "",
|
|
||||||
...(etag ? { "If-None-Match": etag } : {}),
|
|
||||||
},
|
|
||||||
credentials: "omit",
|
|
||||||
});
|
|
||||||
if (res.status === 304) {
|
|
||||||
// Cached config still valid
|
|
||||||
setError(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`shell-api ${res.status}: ${await res.text()}`);
|
|
||||||
}
|
|
||||||
const next = (await res.json()) as ShellConfig;
|
|
||||||
setConfig(next);
|
|
||||||
setError(null);
|
|
||||||
const newEtag = res.headers.get("ETag");
|
|
||||||
if (newEtag) setEtag(newEtag);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e : new Error(String(e)));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [apiUrl, appKey, getToken, etag]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetcher();
|
|
||||||
if (!revalidateMs) return;
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
void fetcher();
|
|
||||||
}, revalidateMs);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [appKey, apiUrl]);
|
|
||||||
|
|
||||||
const value = useMemo<ShellContextValue>(
|
|
||||||
() => ({ config, loading, error, refresh: fetcher }),
|
|
||||||
[config, loading, error, fetcher],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <ShellContext.Provider value={value}>{children}</ShellContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AppShell — composite that renders chrome from ShellConfig ─────────────────
|
|
||||||
|
|
||||||
export type AppShellProps = {
|
export type AppShellProps = {
|
||||||
/** Same args as ShellProvider — AppShell wraps it. */
|
/**
|
||||||
appKey: string;
|
* Pre-resolved shell config. Apps fetch this server-side from
|
||||||
apiUrl: string;
|
* gsc-shell-api and pass it down. The library never makes a
|
||||||
getToken: () => Promise<string>;
|
* network call — no CORS, no cert trust, no browser auth dance.
|
||||||
initialConfig?: ShellConfig;
|
*/
|
||||||
revalidateMs?: number;
|
config: ShellConfig;
|
||||||
|
|
||||||
/** Current pathname for active-route highlight. Default: window.location.pathname. */
|
/** Current pathname for active-route highlight. Default: window.location.pathname. */
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
@@ -168,68 +87,28 @@ export type AppShellProps = {
|
|||||||
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
|
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
|
||||||
onSignOut?: () => void;
|
onSignOut?: () => void;
|
||||||
|
|
||||||
/** Optional: rendered inside <PageHeader> slot. */
|
/** Optional content for the <PageHeader> slot (breadcrumbs, page title, etc.). */
|
||||||
pageHeader?: React.ReactNode;
|
pageHeader?: React.ReactNode;
|
||||||
|
|
||||||
/** Page content. */
|
/** Page content. */
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
||||||
/** Override the wrapper className. */
|
/** Optional className on the outer wrapper. */
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell(props: AppShellProps) {
|
export function AppShell({
|
||||||
return (
|
config,
|
||||||
<ShellProvider
|
currentPath,
|
||||||
appKey={props.appKey}
|
translate,
|
||||||
apiUrl={props.apiUrl}
|
onSignOut,
|
||||||
getToken={props.getToken}
|
pageHeader,
|
||||||
initialConfig={props.initialConfig}
|
className,
|
||||||
revalidateMs={props.revalidateMs}
|
children,
|
||||||
>
|
}: AppShellProps) {
|
||||||
<ShellChrome
|
|
||||||
currentPath={props.currentPath}
|
|
||||||
translate={props.translate}
|
|
||||||
onSignOut={props.onSignOut}
|
|
||||||
pageHeader={props.pageHeader}
|
|
||||||
className={props.className}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</ShellChrome>
|
|
||||||
</ShellProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ShellChromeProps = Pick<AppShellProps, "currentPath" | "translate" | "onSignOut" | "pageHeader" | "className" | "children">;
|
|
||||||
|
|
||||||
function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className, children }: ShellChromeProps) {
|
|
||||||
const { config, error } = useShell();
|
|
||||||
const t = translate ?? ((k: string) => k);
|
const t = translate ?? ((k: string) => k);
|
||||||
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
|
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
|
||||||
|
|
||||||
// Fallback chrome if config never loaded — minimal so the app still renders.
|
|
||||||
if (!config) {
|
|
||||||
return (
|
|
||||||
<PageShell
|
|
||||||
navbar={
|
|
||||||
<Navbar
|
|
||||||
brand={<span className="navbar-brand-text">…</span>}
|
|
||||||
brandHref="/"
|
|
||||||
showSidebarToggle={false}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{error ? (
|
|
||||||
<div className="alert alert-warning mt-3">
|
|
||||||
Chrome unavailable: {error.message}. Showing app content only.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{children}
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
|
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
|
||||||
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
|
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
|
||||||
label: t(m.translationKey),
|
label: t(m.translationKey),
|
||||||
@@ -238,7 +117,11 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const navbarBrand = (
|
const navbarBrand = (
|
||||||
<a className="d-flex align-items-center gap-2" href={config.app.baseUrl} style={{ color: "inherit", textDecoration: "none" }}>
|
<a
|
||||||
|
className="d-flex align-items-center gap-2"
|
||||||
|
href={config.app.baseUrl}
|
||||||
|
style={{ color: "inherit", textDecoration: "none" }}
|
||||||
|
>
|
||||||
<img src={config.branding.logoUrl} alt="" height={24} />
|
<img src={config.branding.logoUrl} alt="" height={24} />
|
||||||
<span className="fw-semibold">{config.branding.productName}</span>
|
<span className="fw-semibold">{config.branding.productName}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -270,7 +153,9 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
|
|||||||
data-bs-toggle="dropdown"
|
data-bs-toggle="dropdown"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
>
|
>
|
||||||
<span className="d-none d-md-inline me-2">{config.user.displayName || t("menu.account")}</span>
|
<span className="d-none d-md-inline me-2">
|
||||||
|
{config.user.displayName || t("menu.account")}
|
||||||
|
</span>
|
||||||
<i className="ph-user-circle" />
|
<i className="ph-user-circle" />
|
||||||
</button>
|
</button>
|
||||||
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
|
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
|
||||||
@@ -279,46 +164,50 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<ShellContext.Provider value={config}>
|
||||||
className={className}
|
<PageShell
|
||||||
navbar={
|
className={className}
|
||||||
<Navbar
|
navbar={
|
||||||
brand={navbarBrand}
|
<Navbar
|
||||||
brandHref={config.app.baseUrl}
|
brand={navbarBrand}
|
||||||
endItems={navbarEnd}
|
brandHref={config.app.baseUrl}
|
||||||
showSidebarToggle
|
endItems={navbarEnd}
|
||||||
/>
|
showSidebarToggle
|
||||||
}
|
/>
|
||||||
pageHeader={pageHeader}
|
}
|
||||||
mainSidebar={
|
pageHeader={pageHeader}
|
||||||
<Sidebar
|
mainSidebar={
|
||||||
variant="main"
|
<Sidebar
|
||||||
color="light"
|
variant="main"
|
||||||
user={{
|
color="light"
|
||||||
name: config.user.displayName || "—",
|
user={{
|
||||||
subtitle: config.user.email,
|
name: config.user.displayName || "—",
|
||||||
}}
|
subtitle: config.user.email,
|
||||||
items={sidebarItems}
|
}}
|
||||||
/>
|
items={sidebarItems}
|
||||||
}
|
/>
|
||||||
footer={
|
}
|
||||||
<Footer
|
footer={
|
||||||
copyright={
|
<Footer
|
||||||
config.branding.footerHtml ? (
|
copyright={
|
||||||
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
|
config.branding.footerHtml ? (
|
||||||
) : (
|
<span dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }} />
|
||||||
<>© {new Date().getFullYear()} GoSec Cloud</>
|
) : (
|
||||||
)
|
<>© {new Date().getFullYear()} GoSec Cloud</>
|
||||||
}
|
)
|
||||||
navItems={footerNavItems}
|
}
|
||||||
/>
|
navItems={footerNavItems}
|
||||||
}
|
/>
|
||||||
>
|
}
|
||||||
{children}
|
>
|
||||||
</PageShell>
|
{children}
|
||||||
|
</PageShell>
|
||||||
|
</ShellContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function toSidebarNavItem(
|
function toSidebarNavItem(
|
||||||
m: ShellMenuItem,
|
m: ShellMenuItem,
|
||||||
currentPath: string,
|
currentPath: string,
|
||||||
@@ -328,21 +217,23 @@ function toSidebarNavItem(
|
|||||||
const item: SidebarNavItem = {
|
const item: SidebarNavItem = {
|
||||||
type: m.children && m.children.length > 0 ? "submenu" : "link",
|
type: m.children && m.children.length > 0 ? "submenu" : "link",
|
||||||
label: t(m.translationKey),
|
label: t(m.translationKey),
|
||||||
href: m.isExternal ? m.href : m.href,
|
href: m.href,
|
||||||
iconClass: m.icon,
|
iconClass: m.icon,
|
||||||
active,
|
active,
|
||||||
};
|
};
|
||||||
if (m.children && m.children.length > 0) {
|
if (m.children && m.children.length > 0) {
|
||||||
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
|
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
|
||||||
// submenu open if any descendant is active
|
// submenu open if any descendant is active
|
||||||
item.isOpen = item.children.some((c) => c.active || (c.children?.some((g) => g.active) ?? false));
|
item.isOpen = item.children.some(
|
||||||
|
(c) => c.active || (c.children?.some((g) => g.active) ?? false),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActiveHref(href: string, currentPath: string): boolean {
|
function isActiveHref(href: string, currentPath: string): boolean {
|
||||||
if (!href) return false;
|
if (!href) return false;
|
||||||
// Strip locale prefix from current path so /en/dashboard matches /dashboard
|
// Strip a leading 2-letter locale prefix so /en/dashboard matches /dashboard.
|
||||||
const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
|
const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
|
||||||
if (href === "/") return stripped === "/";
|
if (href === "/") return stripped === "/";
|
||||||
return stripped === href || stripped.startsWith(`${href}/`);
|
return stripped === href || stripped.startsWith(`${href}/`);
|
||||||
|
|||||||
Reference in New Issue
Block a user