@orsetra/shared-ui 1.9.0 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react"
|
|
|
4
4
|
import { usePathname, useSearchParams, useParams } from "next/navigation"
|
|
5
5
|
import { MainSidebar, type SidebarMode } from "./sidebar/main-sidebar"
|
|
6
6
|
import { Sidebar, SidebarProvider, useSidebar } from "./sidebar/sidebar"
|
|
7
|
-
import { type SidebarMenus, type MainMenuItem, resolveIcon } from "./sidebar/data"
|
|
7
|
+
import { type SidebarMenus, type MainMenuItem, type SidebarMenuMeta, resolveIcon } from "./sidebar/data"
|
|
8
8
|
import { Button } from "../ui"
|
|
9
9
|
import { Logo } from "../ui/logo"
|
|
10
10
|
import { getMenuFromPath } from "../../lib/menu-utils"
|
|
@@ -50,6 +50,7 @@ function LayoutContent({
|
|
|
50
50
|
const [mainMenuItems, setMainMenuItems] = useState<MainMenuItem[]>([])
|
|
51
51
|
const [fetchedSidebarMenus, setFetchedSidebarMenus] = useState<SidebarMenus>({})
|
|
52
52
|
const [sectionLabels, setSectionLabels] = useState<Record<string, string>>({})
|
|
53
|
+
const [sidebarMenuMeta, setSidebarMenuMeta] = useState<Record<string, SidebarMenuMeta>>({})
|
|
53
54
|
|
|
54
55
|
useEffect(() => {
|
|
55
56
|
if (!fetchMenus) return
|
|
@@ -69,6 +70,7 @@ function LayoutContent({
|
|
|
69
70
|
|
|
70
71
|
const menus: SidebarMenus = {}
|
|
71
72
|
const labels: Record<string, string> = {}
|
|
73
|
+
const meta: Record<string, SidebarMenuMeta> = {}
|
|
72
74
|
Object.entries(subMenus).forEach(([key, subMenu]) => {
|
|
73
75
|
menus[key] = subMenu.items.map((item) => ({
|
|
74
76
|
id: item.id,
|
|
@@ -78,9 +80,11 @@ function LayoutContent({
|
|
|
78
80
|
icon: resolveIcon(item.icon),
|
|
79
81
|
}))
|
|
80
82
|
labels[key] = subMenu.header
|
|
83
|
+
meta[key] = { header: subMenu.header, description: subMenu.description }
|
|
81
84
|
})
|
|
82
85
|
setFetchedSidebarMenus(menus)
|
|
83
86
|
setSectionLabels(labels)
|
|
87
|
+
setSidebarMenuMeta(meta)
|
|
84
88
|
})
|
|
85
89
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
90
|
}, [])
|
|
@@ -148,6 +152,7 @@ function LayoutContent({
|
|
|
148
152
|
mode={isHidden ? "expanded" : mode}
|
|
149
153
|
sidebarMenus={effectiveSidebarMenus}
|
|
150
154
|
mainMenuItems={mainMenuItems}
|
|
155
|
+
sidebarMenuMeta={sidebarMenuMeta}
|
|
151
156
|
/>
|
|
152
157
|
|
|
153
158
|
<main className="flex-1 bg-ibm-gray-10 min-h-0 overflow-auto">
|
|
@@ -88,9 +88,15 @@ export interface ApiSubMenuItem {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
export interface ApiSubMenu {
|
|
91
|
-
id:
|
|
92
|
-
header:
|
|
93
|
-
|
|
91
|
+
id: string
|
|
92
|
+
header: string
|
|
93
|
+
description?: string
|
|
94
|
+
items: ApiSubMenuItem[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SidebarMenuMeta {
|
|
98
|
+
header?: string
|
|
99
|
+
description?: string
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
export interface ApiMenuData {
|
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { cn } from "../../../lib/utils"
|
|
5
5
|
import type { MainMenuItem, SubMenuItem } from "./data"
|
|
6
|
+
import { Home, ExternalLink, type LucideIcon } from "lucide-react"
|
|
7
|
+
|
|
8
|
+
export interface FlyoutDefaultLink {
|
|
9
|
+
label: string
|
|
10
|
+
href: string
|
|
11
|
+
description?: string
|
|
12
|
+
icon?: LucideIcon
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
interface MainSidebarFlyoutProps {
|
|
8
|
-
item:
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
item: MainMenuItem | null
|
|
17
|
+
subItems: SubMenuItem[]
|
|
18
|
+
onMouseEnter: () => void
|
|
19
|
+
onMouseLeave: () => void
|
|
20
|
+
main_base_url?: string
|
|
21
|
+
defaultTitle?: string
|
|
22
|
+
defaultDescription?: string
|
|
23
|
+
defaultLinks?: FlyoutDefaultLink[]
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
export function MainSidebarFlyout({
|
|
@@ -17,8 +28,21 @@ export function MainSidebarFlyout({
|
|
|
17
28
|
subItems,
|
|
18
29
|
onMouseEnter,
|
|
19
30
|
onMouseLeave,
|
|
31
|
+
main_base_url = "/",
|
|
32
|
+
defaultTitle = "Camel X Platform",
|
|
33
|
+
defaultDescription,
|
|
34
|
+
defaultLinks = [],
|
|
20
35
|
}: MainSidebarFlyoutProps) {
|
|
21
|
-
const
|
|
36
|
+
const homePath = React.useMemo(() => {
|
|
37
|
+
if (!main_base_url) return "/"
|
|
38
|
+
try {
|
|
39
|
+
return (main_base_url.startsWith("http://") || main_base_url.startsWith("https://"))
|
|
40
|
+
? new URL(main_base_url).pathname
|
|
41
|
+
: main_base_url
|
|
42
|
+
} catch {
|
|
43
|
+
return "/"
|
|
44
|
+
}
|
|
45
|
+
}, [main_base_url])
|
|
22
46
|
|
|
23
47
|
return (
|
|
24
48
|
<div
|
|
@@ -26,46 +50,98 @@ export function MainSidebarFlyout({
|
|
|
26
50
|
onMouseLeave={onMouseLeave}
|
|
27
51
|
className="h-full flex flex-col flex-shrink-0 overflow-hidden bg-gray-50 border-r border-ui-border shadow-[4px_0_20px_-4px_rgba(0,0,0,0.10)] w-72 lg:w-80"
|
|
28
52
|
>
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
{item ? (
|
|
54
|
+
/* ── Hovered item: sub-menu list ─────────────────────────────────── */
|
|
55
|
+
<>
|
|
56
|
+
<div className="flex items-center px-4 lg:px-5 pt-5 pb-1 flex-shrink-0 gap-2.5">
|
|
57
|
+
<item.icon className="h-5 w-5 flex-shrink-0" style={{ color: "#4589ff" }} />
|
|
58
|
+
<h2 className="text-sm font-semibold text-text-primary tracking-wide truncate flex-1">
|
|
59
|
+
{item.label}
|
|
60
|
+
</h2>
|
|
61
|
+
</div>
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)}
|
|
63
|
+
{item.description && (
|
|
64
|
+
<p className="px-4 lg:px-5 pt-1 pb-4 text-xs text-text-secondary leading-relaxed">
|
|
65
|
+
{item.description}
|
|
66
|
+
</p>
|
|
67
|
+
)}
|
|
43
68
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
<nav className="flex-1 overflow-y-auto px-2 lg:px-3 py-2 space-y-0.5">
|
|
70
|
+
{subItems.map((sub) => {
|
|
71
|
+
const SubIcon = sub.icon
|
|
72
|
+
return (
|
|
73
|
+
<a
|
|
74
|
+
key={sub.id}
|
|
75
|
+
href={sub.href}
|
|
76
|
+
className={cn(
|
|
77
|
+
"flex items-start transition-colors border-l-4",
|
|
78
|
+
"px-2 lg:px-3 py-2 gap-x-3",
|
|
79
|
+
"hover:bg-ui-background border-transparent hover:border-interactive",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
<SubIcon className="h-4 w-4 flex-shrink-0 mt-0.5" style={{ color: "#4589ff" }} />
|
|
83
|
+
<div className="min-w-0 flex-1">
|
|
84
|
+
<p className="text-sm font-medium text-text-primary leading-snug truncate">{sub.name}</p>
|
|
85
|
+
{sub.description && (
|
|
86
|
+
<p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{sub.description}</p>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</a>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
</nav>
|
|
93
|
+
</>
|
|
94
|
+
) : (
|
|
95
|
+
/* ── Default state: centré verticalement ────────────────────────── */
|
|
96
|
+
<div className="flex flex-col h-full items-center justify-center px-6 gap-6">
|
|
97
|
+
{/* Titre + texte + CTA */}
|
|
98
|
+
<div className="flex flex-col items-center gap-3 w-full">
|
|
99
|
+
<span className="text-sm font-semibold text-text-primary text-center">{defaultTitle}</span>
|
|
100
|
+
{defaultDescription && (
|
|
101
|
+
<p className="text-xs text-text-secondary text-center leading-relaxed">{defaultDescription}</p>
|
|
102
|
+
)}
|
|
49
103
|
<a
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
className={cn(
|
|
53
|
-
"flex items-start transition-colors border-l-4",
|
|
54
|
-
"px-2 lg:px-3 py-2 gap-x-3",
|
|
55
|
-
"hover:bg-ui-background border-transparent hover:border-interactive",
|
|
56
|
-
)}
|
|
104
|
+
href={homePath}
|
|
105
|
+
className="flex items-center justify-center gap-2 w-full px-3 py-2 border border-ui-border text-text-secondary hover:text-text-primary hover:bg-ui-background text-sm font-medium transition-colors"
|
|
57
106
|
>
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
<p className="text-sm font-medium text-text-primary leading-snug truncate">{sub.name}</p>
|
|
61
|
-
{sub.description && (
|
|
62
|
-
<p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{sub.description}</p>
|
|
63
|
-
)}
|
|
64
|
-
</div>
|
|
107
|
+
<Home className="h-4 w-4 flex-shrink-0" />
|
|
108
|
+
Tableau de bord
|
|
65
109
|
</a>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Liens ressources */}
|
|
113
|
+
{defaultLinks.length > 0 && (
|
|
114
|
+
<nav className="w-full space-y-0.5">
|
|
115
|
+
<p className="px-2 pb-1 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
|
|
116
|
+
Ressources
|
|
117
|
+
</p>
|
|
118
|
+
{defaultLinks.map((link, i) => {
|
|
119
|
+
const LinkIcon = link.icon ?? ExternalLink
|
|
120
|
+
const isExternal = link.href.startsWith("http://") || link.href.startsWith("https://")
|
|
121
|
+
return (
|
|
122
|
+
<a
|
|
123
|
+
key={i}
|
|
124
|
+
href={link.href}
|
|
125
|
+
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
126
|
+
className="flex items-start gap-x-3 px-2 py-2 border-l-4 border-transparent hover:bg-ui-background hover:border-interactive transition-colors"
|
|
127
|
+
>
|
|
128
|
+
<LinkIcon className="h-4 w-4 flex-shrink-0 mt-0.5 text-text-secondary" />
|
|
129
|
+
<div className="min-w-0 flex-1">
|
|
130
|
+
<p className="text-sm font-medium text-text-primary leading-snug flex items-center gap-1.5">
|
|
131
|
+
{link.label}
|
|
132
|
+
{isExternal && <ExternalLink className="h-3 w-3 text-text-secondary flex-shrink-0" />}
|
|
133
|
+
</p>
|
|
134
|
+
{link.description && (
|
|
135
|
+
<p className="text-xs text-text-secondary leading-relaxed mt-0.5 line-clamp-2">{link.description}</p>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</a>
|
|
139
|
+
)
|
|
140
|
+
})}
|
|
141
|
+
</nav>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
69
145
|
</div>
|
|
70
146
|
)
|
|
71
147
|
}
|
|
@@ -4,10 +4,14 @@ import * as React from "react"
|
|
|
4
4
|
import { cn } from "../../../lib/utils"
|
|
5
5
|
import { Button } from "../../ui/button"
|
|
6
6
|
import { Logo } from "../../ui/logo"
|
|
7
|
-
import { type MainMenuItem, type SidebarMenus } from "./data"
|
|
7
|
+
import { type MainMenuItem, type SidebarMenus, type SidebarMenuMeta } from "./data"
|
|
8
|
+
|
|
9
|
+
export type { SidebarMenuMeta }
|
|
8
10
|
import { X, Menu, ChevronDown, ChevronRight } from "lucide-react"
|
|
9
11
|
import { useIsMobile } from "../../../hooks/use-mobile"
|
|
10
|
-
import { MainSidebarFlyout } from "./main-sidebar-flyout"
|
|
12
|
+
import { MainSidebarFlyout, type FlyoutDefaultLink } from "./main-sidebar-flyout"
|
|
13
|
+
|
|
14
|
+
export type { FlyoutDefaultLink }
|
|
11
15
|
|
|
12
16
|
export type SidebarMode = 'expanded' | 'minimized' | 'hidden'
|
|
13
17
|
|
|
@@ -21,6 +25,8 @@ interface MainSidebarProps {
|
|
|
21
25
|
onSecondarySidebarOpen?: () => void
|
|
22
26
|
sidebarMenus?: SidebarMenus
|
|
23
27
|
mainMenuItems?: MainMenuItem[]
|
|
28
|
+
/** Per-submenu metadata (header + description) derived from the API menu response. */
|
|
29
|
+
sidebarMenuMeta?: Record<string, SidebarMenuMeta>
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
export function MainSidebar({
|
|
@@ -33,6 +39,7 @@ export function MainSidebar({
|
|
|
33
39
|
onSecondarySidebarOpen,
|
|
34
40
|
sidebarMenus = {},
|
|
35
41
|
mainMenuItems = [],
|
|
42
|
+
sidebarMenuMeta = {},
|
|
36
43
|
}: MainSidebarProps) {
|
|
37
44
|
const isMinimized = mode === 'minimized'
|
|
38
45
|
const isMobile = useIsMobile()
|
|
@@ -66,6 +73,40 @@ export function MainSidebar({
|
|
|
66
73
|
setHoveredItem(item)
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
const applyTemplateVars = (href: string): string => {
|
|
77
|
+
// Guard: SSR has no window/localStorage
|
|
78
|
+
if (typeof window === "undefined") return href
|
|
79
|
+
try {
|
|
80
|
+
const projRaw = localStorage.getItem("current-business-unit")
|
|
81
|
+
const envRaw = localStorage.getItem("current-environment")
|
|
82
|
+
// Parse carefully: value may be null (not yet set), invalid JSON, or a non-object
|
|
83
|
+
const currentProject = typeof projRaw === "string"
|
|
84
|
+
? (JSON.parse(projRaw)?.state?.currentProject ?? "")
|
|
85
|
+
: ""
|
|
86
|
+
const currentEnv = typeof envRaw === "string"
|
|
87
|
+
? (JSON.parse(envRaw)?.state?.currentEnv ?? "")
|
|
88
|
+
: ""
|
|
89
|
+
// Only replace when the value is a non-empty string; if empty, leave the
|
|
90
|
+
// placeholder in the URL so the link is visibly broken rather than silently
|
|
91
|
+
// producing a protocol-relative URL (e.g. "//path") that navigates incorrectly.
|
|
92
|
+
let result = href
|
|
93
|
+
if (currentProject && typeof currentProject === "string") {
|
|
94
|
+
result = result.replace(/\$\{currentProject\}/g, currentProject)
|
|
95
|
+
}
|
|
96
|
+
if (currentEnv && typeof currentEnv === "string") {
|
|
97
|
+
result = result.replace(/\$\{currentEnv\}/g, currentEnv)
|
|
98
|
+
}
|
|
99
|
+
return result
|
|
100
|
+
} catch {
|
|
101
|
+
// localStorage blocked (Safari private mode), JSON parse error, etc. — return unchanged
|
|
102
|
+
return href
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const safeUrlPathname = (url: string): string => {
|
|
107
|
+
try { return new URL(url).pathname } catch { return "/" }
|
|
108
|
+
}
|
|
109
|
+
|
|
69
110
|
const handleMenuClick = (item: MainMenuItem) => {
|
|
70
111
|
const menuId = item.id
|
|
71
112
|
const hasSubMenu = (sidebarMenus[menuId]?.length ?? 0) > 0
|
|
@@ -81,26 +122,47 @@ export function MainSidebar({
|
|
|
81
122
|
if (!isMinimized && onSecondarySidebarOpen) onSecondarySidebarOpen()
|
|
82
123
|
|
|
83
124
|
if (item.href) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
125
|
+
try {
|
|
126
|
+
const resolved = applyTemplateVars(item.href)
|
|
127
|
+
const path = resolved.startsWith('http://') || resolved.startsWith('https://')
|
|
128
|
+
? safeUrlPathname(resolved)
|
|
129
|
+
: resolved
|
|
130
|
+
window.location.href = path
|
|
131
|
+
} catch {
|
|
132
|
+
// navigation error — don't crash the sidebar
|
|
133
|
+
}
|
|
88
134
|
}
|
|
89
135
|
|
|
90
136
|
if (!isMinimized) onToggle()
|
|
91
137
|
}
|
|
92
138
|
|
|
93
139
|
const buildSubItemHref = (menuId: string, href: string): string => {
|
|
94
|
-
|
|
95
|
-
|
|
140
|
+
try {
|
|
141
|
+
const resolved = applyTemplateVars(href)
|
|
142
|
+
if (resolved.startsWith('http://') || resolved.startsWith('https://')) {
|
|
143
|
+
return safeUrlPathname(resolved)
|
|
144
|
+
}
|
|
145
|
+
const cleanHref = resolved.replace(/^\//, '')
|
|
146
|
+
const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
|
|
147
|
+
? safeUrlPathname(main_base_url).replace(/\/$/, '')
|
|
148
|
+
: (main_base_url ?? "").replace(/\/$/, '')
|
|
149
|
+
return `${cleanBase}/${menuId}/${cleanHref}`
|
|
150
|
+
} catch {
|
|
151
|
+
return "/"
|
|
96
152
|
}
|
|
97
|
-
const cleanHref = href.replace(/^\//, '')
|
|
98
|
-
const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
|
|
99
|
-
? new URL(main_base_url).pathname.replace(/\/$/, '')
|
|
100
|
-
: main_base_url.replace(/\/$/, '')
|
|
101
|
-
return `${cleanBase}/${menuId}/${cleanHref}`
|
|
102
153
|
}
|
|
103
154
|
|
|
155
|
+
// Default flyout content derived from the "main" submenu in the loaded menu data
|
|
156
|
+
const mainMeta = sidebarMenuMeta["main"] ?? {}
|
|
157
|
+
const defaultTitle = mainMeta.header ?? "Camel X Platform"
|
|
158
|
+
const defaultDesc = mainMeta.description ?? undefined
|
|
159
|
+
const defaultLinks: FlyoutDefaultLink[] = (sidebarMenus["main"] ?? []).map(sub => ({
|
|
160
|
+
label: sub.name,
|
|
161
|
+
href: sub.href,
|
|
162
|
+
description: sub.description,
|
|
163
|
+
icon: sub.icon,
|
|
164
|
+
}))
|
|
165
|
+
|
|
104
166
|
const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
|
|
105
167
|
e.preventDefault()
|
|
106
168
|
window.location.href = buildSubItemHref(menuId, href)
|
|
@@ -157,9 +219,14 @@ export function MainSidebar({
|
|
|
157
219
|
const isFlyActive = hoveredItem?.id === item.id
|
|
158
220
|
|
|
159
221
|
const itemHref = !hasSubMenu && item.href
|
|
160
|
-
? (
|
|
161
|
-
|
|
162
|
-
|
|
222
|
+
? (() => {
|
|
223
|
+
try {
|
|
224
|
+
const resolved = applyTemplateVars(item.href)
|
|
225
|
+
return resolved.startsWith('http://') || resolved.startsWith('https://')
|
|
226
|
+
? safeUrlPathname(resolved)
|
|
227
|
+
: resolved
|
|
228
|
+
} catch { return item.href }
|
|
229
|
+
})()
|
|
163
230
|
: undefined
|
|
164
231
|
|
|
165
232
|
const itemCls = cn(
|
|
@@ -231,15 +298,23 @@ export function MainSidebar({
|
|
|
231
298
|
</div>
|
|
232
299
|
|
|
233
300
|
{/* ── Flyout — desktop only, flex child to the right of nav ─────────── */}
|
|
234
|
-
{
|
|
301
|
+
{/* Shown when the sidebar is open (default state) or an item is hovered (sub-menu state) */}
|
|
302
|
+
{!isMobile && (hoveredItem || (isOpen && !isMinimized)) && (
|
|
235
303
|
<MainSidebarFlyout
|
|
236
304
|
item={hoveredItem}
|
|
237
|
-
subItems={
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
305
|
+
subItems={hoveredItem
|
|
306
|
+
? (sidebarMenus[hoveredItem.id] ?? []).map(sub => ({
|
|
307
|
+
...sub,
|
|
308
|
+
href: buildSubItemHref(hoveredItem.id, sub.href),
|
|
309
|
+
}))
|
|
310
|
+
: []
|
|
311
|
+
}
|
|
241
312
|
onMouseEnter={cancelCloseTimer}
|
|
242
313
|
onMouseLeave={startCloseTimer}
|
|
314
|
+
main_base_url={main_base_url}
|
|
315
|
+
defaultTitle={defaultTitle}
|
|
316
|
+
defaultDescription={defaultDesc}
|
|
317
|
+
defaultLinks={defaultLinks}
|
|
243
318
|
/>
|
|
244
319
|
)}
|
|
245
320
|
|