@orsetra/shared-ui 1.9.2 → 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,
@@ -80,7 +80,16 @@ function LayoutContent({
80
80
  icon: resolveIcon(item.icon),
81
81
  }))
82
82
  labels[key] = subMenu.header
83
- meta[key] = { header: subMenu.header, description: subMenu.description }
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
+ }
84
93
  })
85
94
  setFetchedSidebarMenus(menus)
86
95
  setSectionLabels(labels)
@@ -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 {
@@ -91,12 +92,15 @@ export interface ApiSubMenu {
91
92
  id: string
92
93
  header: string
93
94
  description?: string
95
+ /** Resource/doc links shown in the flyout default state (distinct from nav items) */
96
+ links?: ApiSubMenuItem[]
94
97
  items: ApiSubMenuItem[]
95
98
  }
96
99
 
97
100
  export interface SidebarMenuMeta {
98
101
  header?: string
99
102
  description?: string
103
+ links?: Array<{ label: string; href: string; description?: string; icon?: string }>
100
104
  }
101
105
 
102
106
  export interface ApiMenuData {
@@ -156,6 +160,7 @@ const ICON_MAP: Record<string, LucideIcon> = {
156
160
  FileText,
157
161
  Code,
158
162
  Cloud,
163
+ LifeBuoy,
159
164
  }
160
165
 
161
166
  export function resolveIcon(name: string): LucideIcon {
@@ -3,13 +3,15 @@
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 {
@@ -21,6 +23,7 @@ interface MainSidebarFlyoutProps {
21
23
  defaultTitle?: string
22
24
  defaultDescription?: string
23
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,6 +97,33 @@ 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
128
  /* ── Default state: centré verticalement ────────────────────────── */
96
129
  <div className="flex flex-col h-full items-center justify-center px-6 gap-6">
@@ -102,7 +135,7 @@ export function MainSidebarFlyout({
102
135
  )}
103
136
  <a
104
137
  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"
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"
106
139
  >
107
140
  <Home className="h-4 w-4 flex-shrink-0" />
108
141
  Tableau de bord
@@ -116,8 +149,12 @@ export function MainSidebarFlyout({
116
149
  Ressources
117
150
  </p>
118
151
  {defaultLinks.map((link, i) => {
119
- const LinkIcon = link.icon ?? ExternalLink
120
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
121
158
  return (
122
159
  <a
123
160
  key={i}
@@ -10,6 +10,7 @@ export type { SidebarMenuMeta }
10
10
  import { X, Menu, ChevronDown, ChevronRight } from "lucide-react"
11
11
  import { useIsMobile } from "../../../hooks/use-mobile"
12
12
  import { MainSidebarFlyout, type FlyoutDefaultLink } from "./main-sidebar-flyout"
13
+ import { useQuickAccess } from "../../../hooks/use-quick-access"
13
14
 
14
15
  export type { FlyoutDefaultLink }
15
16
 
@@ -43,6 +44,7 @@ export function MainSidebar({
43
44
  }: MainSidebarProps) {
44
45
  const isMinimized = mode === 'minimized'
45
46
  const isMobile = useIsMobile()
47
+ const { items: quickAccessItems } = useQuickAccess()
46
48
 
47
49
  // Accordion — mobile only
48
50
  const [expandedMenu, setExpandedMenu] = React.useState<string | null>(null)
@@ -152,15 +154,15 @@ export function MainSidebar({
152
154
  }
153
155
  }
154
156
 
155
- // Default flyout content derived from the "main" submenu in the loaded menu data
157
+ // Default flyout content derived from the menu metadata
156
158
  const mainMeta = sidebarMenuMeta["main"] ?? {}
157
159
  const defaultTitle = mainMeta.header ?? "Camel X Platform"
158
160
  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,
161
+ const defaultLinks: FlyoutDefaultLink[] = (mainMeta.links ?? []).map(l => ({
162
+ label: l.label,
163
+ href: l.href,
164
+ description: l.description,
165
+ icon: l.icon,
164
166
  }))
165
167
 
166
168
  const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
@@ -315,6 +317,7 @@ export function MainSidebar({
315
317
  defaultTitle={defaultTitle}
316
318
  defaultDescription={defaultDesc}
317
319
  defaultLinks={defaultLinks}
320
+ quickAccessItems={quickAccessItems}
318
321
  />
319
322
  )}
320
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.2",
3
+ "version": "1.10.0",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",