@orsetra/shared-ui 1.9.1 → 1.10.0

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.
@@ -1,6 +1,7 @@
1
1
  // Layout components exports
2
2
  export { MainSidebar, type SidebarMode } from './sidebar/main-sidebar'
3
- export { resolveIcon, type MainMenuItem, type SubMenuItem, type SidebarMenus, type MenuApiResponse, type ApiMenuData, type ApiMainMenuItem, type ApiSubMenuItem, type ApiSubMenu, type OrgInfo, type Project } from './sidebar/data'
3
+ export { resolveIcon, type MainMenuItem, type SubMenuItem, type SidebarMenus, type SidebarMenuMeta, type MenuApiResponse, type ApiMenuData, type ApiMainMenuItem, type ApiSubMenuItem, type ApiSubMenu, type OrgInfo, type Project } from './sidebar/data'
4
+ export { QuickAccessTracker } from './sidebar/quick-access-tracker'
4
5
  export {
5
6
  Sidebar,
6
7
  SidebarContent,
@@ -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,20 @@ function LayoutContent({
78
80
  icon: resolveIcon(item.icon),
79
81
  }))
80
82
  labels[key] = subMenu.header
83
+ meta[key] = {
84
+ header: subMenu.header,
85
+ description: subMenu.description,
86
+ links: subMenu.links?.map(l => ({
87
+ label: l.label,
88
+ href: l.href,
89
+ description: l.description,
90
+ icon: l.icon,
91
+ })),
92
+ }
81
93
  })
82
94
  setFetchedSidebarMenus(menus)
83
95
  setSectionLabels(labels)
96
+ setSidebarMenuMeta(meta)
84
97
  })
85
98
  // eslint-disable-next-line react-hooks/exhaustive-deps
86
99
  }, [])
@@ -148,6 +161,7 @@ function LayoutContent({
148
161
  mode={isHidden ? "expanded" : mode}
149
162
  sidebarMenus={effectiveSidebarMenus}
150
163
  mainMenuItems={mainMenuItems}
164
+ sidebarMenuMeta={sidebarMenuMeta}
151
165
  />
152
166
 
153
167
  <main className="flex-1 bg-ibm-gray-10 min-h-0 overflow-auto">
@@ -36,6 +36,7 @@ import {
36
36
  ArrowRightLeft,
37
37
  ArrowLeftRight,
38
38
  Puzzle,
39
+ LifeBuoy,
39
40
  } from "lucide-react"
40
41
 
41
42
  export interface MainMenuItem {
@@ -88,9 +89,18 @@ export interface ApiSubMenuItem {
88
89
  }
89
90
 
90
91
  export interface ApiSubMenu {
91
- id: string
92
- header: string
93
- items: ApiSubMenuItem[]
92
+ id: string
93
+ header: string
94
+ description?: string
95
+ /** Resource/doc links shown in the flyout default state (distinct from nav items) */
96
+ links?: ApiSubMenuItem[]
97
+ items: ApiSubMenuItem[]
98
+ }
99
+
100
+ export interface SidebarMenuMeta {
101
+ header?: string
102
+ description?: string
103
+ links?: Array<{ label: string; href: string; description?: string; icon?: string }>
94
104
  }
95
105
 
96
106
  export interface ApiMenuData {
@@ -150,6 +160,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
150
160
  FileText,
151
161
  Code,
152
162
  Cloud,
163
+ LifeBuoy,
153
164
  }
154
165
 
155
166
  export function resolveIcon(name: string): LucideIcon {
@@ -3,24 +3,27 @@
3
3
  import * as React from "react"
4
4
  import { cn } from "../../../lib/utils"
5
5
  import type { MainMenuItem, SubMenuItem } from "./data"
6
+ import { resolveIcon } from "./data"
6
7
  import { Home, ExternalLink, type LucideIcon } from "lucide-react"
8
+ import type { QuickAccessItem } from "../../../hooks/use-quick-access"
7
9
 
8
10
  export interface FlyoutDefaultLink {
9
11
  label: string
10
12
  href: string
11
13
  description?: string
12
- icon?: LucideIcon
14
+ icon?: LucideIcon | string
13
15
  }
14
16
 
15
17
  interface MainSidebarFlyoutProps {
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[]
18
+ item: MainMenuItem | null
19
+ subItems: SubMenuItem[]
20
+ onMouseEnter: () => void
21
+ onMouseLeave: () => void
22
+ main_base_url?: string
23
+ defaultTitle?: string
24
+ defaultDescription?: string
25
+ defaultLinks?: FlyoutDefaultLink[]
26
+ quickAccessItems?: QuickAccessItem[]
24
27
  }
25
28
 
26
29
  export function MainSidebarFlyout({
@@ -32,6 +35,7 @@ export function MainSidebarFlyout({
32
35
  defaultTitle = "Camel X Platform",
33
36
  defaultDescription,
34
37
  defaultLinks = [],
38
+ quickAccessItems = [],
35
39
  }: MainSidebarFlyoutProps) {
36
40
  const homePath = React.useMemo(() => {
37
41
  if (!main_base_url) return "/"
@@ -44,6 +48,8 @@ export function MainSidebarFlyout({
44
48
  }
45
49
  }, [main_base_url])
46
50
 
51
+ const hasQuickAccess = quickAccessItems.length > 0
52
+
47
53
  return (
48
54
  <div
49
55
  onMouseEnter={onMouseEnter}
@@ -91,48 +97,70 @@ export function MainSidebarFlyout({
91
97
  })}
92
98
  </nav>
93
99
  </>
100
+ ) : hasQuickAccess ? (
101
+ /* ── Quick access: pages recently visited ────────────────────────── */
102
+ <div className="flex flex-col h-full items-center justify-center px-6 gap-6">
103
+ <div className="w-full">
104
+ <p className="px-2 pb-1 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
105
+ Accès rapide
106
+ </p>
107
+ <nav className="space-y-0.5">
108
+ {quickAccessItems.map((qa) => {
109
+ const QAIcon = qa.icon ? resolveIcon(qa.icon) : null
110
+ return (
111
+ <a
112
+ key={qa.id}
113
+ href={qa.href}
114
+ className="flex items-center gap-x-3 px-2 py-2 border-l-4 border-transparent hover:bg-ui-background hover:border-interactive transition-colors"
115
+ >
116
+ {QAIcon
117
+ ? <QAIcon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
118
+ : <span className="h-4 w-4 flex-shrink-0" />
119
+ }
120
+ <p className="text-sm font-medium text-text-primary leading-snug truncate">{qa.label}</p>
121
+ </a>
122
+ )
123
+ })}
124
+ </nav>
125
+ </div>
126
+ </div>
94
127
  ) : (
95
- /* ── Default state: welcome + CTA + doc links ───────────────────── */
96
- <div className="flex flex-col h-full">
97
- {/* Header */}
98
- <div className="px-5 pt-6 pb-5 border-b border-ui-border flex-shrink-0">
99
- <div className="flex items-center gap-2.5 mb-3">
100
- <div className="h-8 w-8 rounded-md bg-interactive flex items-center justify-center flex-shrink-0">
101
- <Home className="h-4 w-4 text-white" />
102
- </div>
103
- <span className="text-sm font-semibold text-text-primary truncate">{defaultTitle}</span>
104
- </div>
105
-
128
+ /* ── Default state: centré verticalement ────────────────────────── */
129
+ <div className="flex flex-col h-full items-center justify-center px-6 gap-6">
130
+ {/* Titre + texte + CTA */}
131
+ <div className="flex flex-col items-center gap-3 w-full">
132
+ <span className="text-sm font-semibold text-text-primary text-center">{defaultTitle}</span>
106
133
  {defaultDescription && (
107
- <p className="text-xs text-text-secondary leading-relaxed mb-4">
108
- {defaultDescription}
109
- </p>
134
+ <p className="text-xs text-text-secondary text-center leading-relaxed">{defaultDescription}</p>
110
135
  )}
111
-
112
136
  <a
113
137
  href={homePath}
114
- className="flex items-center justify-center gap-2 w-full px-3 py-2 bg-interactive hover:bg-interactive-hover text-white text-sm font-medium transition-colors"
138
+ className="flex items-center justify-center gap-2 w-full px-3 py-2 border border-interactive text-interactive hover:bg-interactive/10 text-sm font-medium transition-colors"
115
139
  >
116
140
  <Home className="h-4 w-4 flex-shrink-0" />
117
141
  Tableau de bord
118
142
  </a>
119
143
  </div>
120
144
 
121
- {/* Doc / resource links */}
145
+ {/* Liens ressources */}
122
146
  {defaultLinks.length > 0 && (
123
- <nav className="flex-1 overflow-y-auto px-2 lg:px-3 py-3 space-y-0.5">
147
+ <nav className="w-full space-y-0.5">
124
148
  <p className="px-2 pb-1 text-[10px] font-semibold text-text-secondary uppercase tracking-widest">
125
149
  Ressources
126
150
  </p>
127
151
  {defaultLinks.map((link, i) => {
128
- const LinkIcon = link.icon ?? ExternalLink
129
152
  const isExternal = link.href.startsWith("http://") || link.href.startsWith("https://")
153
+ const LinkIcon: LucideIcon = !link.icon
154
+ ? ExternalLink
155
+ : typeof link.icon === "string"
156
+ ? resolveIcon(link.icon)
157
+ : link.icon
130
158
  return (
131
159
  <a
132
160
  key={i}
133
161
  href={link.href}
134
162
  {...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
135
- className="flex items-start gap-x-3 px-2 lg:px-3 py-2 border-l-4 border-transparent hover:bg-ui-background hover:border-interactive transition-colors"
163
+ 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"
136
164
  >
137
165
  <LinkIcon className="h-4 w-4 flex-shrink-0 mt-0.5 text-text-secondary" />
138
166
  <div className="min-w-0 flex-1">
@@ -4,10 +4,13 @@ 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
12
  import { MainSidebarFlyout, type FlyoutDefaultLink } from "./main-sidebar-flyout"
13
+ import { useQuickAccess } from "../../../hooks/use-quick-access"
11
14
 
12
15
  export type { FlyoutDefaultLink }
13
16
 
@@ -23,9 +26,8 @@ interface MainSidebarProps {
23
26
  onSecondarySidebarOpen?: () => void
24
27
  sidebarMenus?: SidebarMenus
25
28
  mainMenuItems?: MainMenuItem[]
26
- flyoutDefaultTitle?: string
27
- flyoutDefaultDescription?: string
28
- flyoutDefaultLinks?: FlyoutDefaultLink[]
29
+ /** Per-submenu metadata (header + description) derived from the API menu response. */
30
+ sidebarMenuMeta?: Record<string, SidebarMenuMeta>
29
31
  }
30
32
 
31
33
  export function MainSidebar({
@@ -38,12 +40,11 @@ export function MainSidebar({
38
40
  onSecondarySidebarOpen,
39
41
  sidebarMenus = {},
40
42
  mainMenuItems = [],
41
- flyoutDefaultTitle,
42
- flyoutDefaultDescription,
43
- flyoutDefaultLinks = [],
43
+ sidebarMenuMeta = {},
44
44
  }: MainSidebarProps) {
45
45
  const isMinimized = mode === 'minimized'
46
46
  const isMobile = useIsMobile()
47
+ const { items: quickAccessItems } = useQuickAccess()
47
48
 
48
49
  // Accordion — mobile only
49
50
  const [expandedMenu, setExpandedMenu] = React.useState<string | null>(null)
@@ -153,6 +154,17 @@ export function MainSidebar({
153
154
  }
154
155
  }
155
156
 
157
+ // Default flyout content derived from the menu metadata
158
+ const mainMeta = sidebarMenuMeta["main"] ?? {}
159
+ const defaultTitle = mainMeta.header ?? "Camel X Platform"
160
+ const defaultDesc = mainMeta.description ?? undefined
161
+ const defaultLinks: FlyoutDefaultLink[] = (mainMeta.links ?? []).map(l => ({
162
+ label: l.label,
163
+ href: l.href,
164
+ description: l.description,
165
+ icon: l.icon,
166
+ }))
167
+
156
168
  const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
157
169
  e.preventDefault()
158
170
  window.location.href = buildSubItemHref(menuId, href)
@@ -302,9 +314,10 @@ export function MainSidebar({
302
314
  onMouseEnter={cancelCloseTimer}
303
315
  onMouseLeave={startCloseTimer}
304
316
  main_base_url={main_base_url}
305
- defaultTitle={flyoutDefaultTitle}
306
- defaultDescription={flyoutDefaultDescription}
307
- defaultLinks={flyoutDefaultLinks}
317
+ defaultTitle={defaultTitle}
318
+ defaultDescription={defaultDesc}
319
+ defaultLinks={defaultLinks}
320
+ quickAccessItems={quickAccessItems}
308
321
  />
309
322
  )}
310
323
 
@@ -0,0 +1,36 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+ import { usePathname } from "next/navigation"
5
+ import { useQuickAccess } from "../../../hooks/use-quick-access"
6
+
7
+ interface QuickAccessTrackerProps {
8
+ /** Human-readable label shown in the quick-access list */
9
+ label: string
10
+ /** Icon name (resolved via resolveIcon). Defaults to "Box". */
11
+ icon?: string
12
+ /** Set to false to skip tracking this page (e.g. list/index pages) */
13
+ enabled?: boolean
14
+ }
15
+
16
+ /**
17
+ * Drop this inside any page or layout to register that page in the
18
+ * sidebar's quick-access list. Tracks on mount and whenever pathname changes.
19
+ * Renders nothing.
20
+ */
21
+ export function QuickAccessTracker({ label, icon, enabled = true }: QuickAccessTrackerProps) {
22
+ const pathname = usePathname()
23
+ const { addItem } = useQuickAccess()
24
+ // Stable ref so the effect doesn't re-run when addItem reference changes
25
+ const addRef = useRef(addItem)
26
+ addRef.current = addItem
27
+
28
+ useEffect(() => {
29
+ if (!enabled || !label || !pathname) return
30
+ addRef.current({ label, href: pathname, icon })
31
+ // Re-track whenever the actual page changes
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [pathname, label, icon, enabled])
34
+
35
+ return null
36
+ }
package/hooks/index.ts CHANGED
@@ -3,3 +3,4 @@ export { useAuth } from './use-auth';
3
3
  export { useToast } from './use-toast';
4
4
  export { useIsMobile } from './use-mobile';
5
5
  export { useWebSocket } from './use-websocket';
6
+ export { useQuickAccess, type QuickAccessItem } from './use-quick-access';
@@ -0,0 +1,74 @@
1
+ "use client"
2
+
3
+ import { useState, useEffect, useCallback } from "react"
4
+
5
+ const STORAGE_KEY = "sidebar:quick-access"
6
+ const MAX_ITEMS = 4
7
+
8
+ export interface QuickAccessItem {
9
+ id: string
10
+ label: string
11
+ href: string
12
+ icon?: string
13
+ }
14
+
15
+ function readStorage(): QuickAccessItem[] {
16
+ if (typeof window === "undefined") return []
17
+ try {
18
+ const raw = localStorage.getItem(STORAGE_KEY)
19
+ if (!raw) return []
20
+ const parsed = JSON.parse(raw)
21
+ return Array.isArray(parsed)
22
+ ? parsed.filter(
23
+ (i): i is QuickAccessItem =>
24
+ i && typeof i.id === "string" && typeof i.label === "string" && typeof i.href === "string"
25
+ )
26
+ : []
27
+ } catch {
28
+ return []
29
+ }
30
+ }
31
+
32
+ function writeStorage(items: QuickAccessItem[]): void {
33
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) } catch {}
34
+ }
35
+
36
+ export function useQuickAccess() {
37
+ const [items, setItems] = useState<QuickAccessItem[]>(readStorage)
38
+
39
+ // Sync across tabs
40
+ useEffect(() => {
41
+ const onStorage = (e: StorageEvent) => {
42
+ if (e.key === STORAGE_KEY) setItems(readStorage())
43
+ }
44
+ window.addEventListener("storage", onStorage)
45
+ return () => window.removeEventListener("storage", onStorage)
46
+ }, [])
47
+
48
+ const addItem = useCallback((item: Omit<QuickAccessItem, "id"> & { id?: string }) => {
49
+ setItems(prev => {
50
+ const id = item.id ?? item.href
51
+ const next = [
52
+ { id, label: item.label, href: item.href, icon: item.icon },
53
+ ...prev.filter(i => i.href !== item.href),
54
+ ].slice(0, MAX_ITEMS)
55
+ writeStorage(next)
56
+ return next
57
+ })
58
+ }, [])
59
+
60
+ const removeItem = useCallback((href: string) => {
61
+ setItems(prev => {
62
+ const next = prev.filter(i => i.href !== href)
63
+ writeStorage(next)
64
+ return next
65
+ })
66
+ }, [])
67
+
68
+ const clearItems = useCallback(() => {
69
+ setItems([])
70
+ try { localStorage.removeItem(STORAGE_KEY) } catch {}
71
+ }, [])
72
+
73
+ return { items, addItem, removeItem, clearItems }
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",