@orsetra/shared-ui 1.2.4 → 1.3.1

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,328 +1,159 @@
1
- "use client"
2
-
3
- import { useState, useEffect, useRef } from "react"
4
- import { usePathname, useSearchParams, useParams } from "next/navigation"
5
- import { MainSidebar, type SidebarMode } from "./sidebar/main-sidebar"
6
- import { Sidebar, SidebarProvider, useSidebar } from "./sidebar/sidebar"
7
- import { type SidebarMenus, type MainMenuItem, type MenuApiResponse, type OrgInfo, type Project, resolveIcon } from "./sidebar/data"
8
- import { UserMenu, Button, type UserMenuConfig } from "../ui"
9
- import { getMenuFromPath } from "../../lib/menu-utils"
10
- import { useIsMobile } from "../../hooks/use-mobile"
11
- import { cn } from "../../lib/utils"
12
- import { Menu, ChevronDown, ChevronRight } from "lucide-react"
13
-
14
- // ─── Dropdown Organisation / Projets ────────────────────────────────────────
15
-
16
- function OrgProjectDropdown({
17
- loadOrg,
18
- loadProjects,
19
- currentProject,
20
- onProjectChange,
21
- }: {
22
- loadOrg: () => Promise<OrgInfo>
23
- loadProjects: () => Promise<Project[]>
24
- currentProject?: string | null
25
- onProjectChange: (id: string) => void
26
- }) {
27
- const [org, setOrg] = useState<OrgInfo | null>(null)
28
- const [open, setOpen] = useState(false)
29
- const [projects, setProjects] = useState<Project[]>([])
30
- const [loading, setLoading] = useState(false)
31
- const ref = useRef<HTMLDivElement>(null)
32
-
33
- useEffect(() => {
34
- loadOrg().then(setOrg).catch(() => {})
35
- // eslint-disable-next-line react-hooks/exhaustive-deps
36
- }, [])
37
-
38
- useEffect(() => {
39
- if (!open) return
40
- function handleOutside(e: MouseEvent) {
41
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
42
- }
43
- document.addEventListener("mousedown", handleOutside)
44
- return () => document.removeEventListener("mousedown", handleOutside)
45
- }, [open])
46
-
47
- if (!org) return null
48
-
49
- const handleToggle = async () => {
50
- if (!open && projects.length === 0) {
51
- setLoading(true)
52
- try {
53
- const data = await loadProjects()
54
- setProjects(data)
55
- } finally {
56
- setLoading(false)
57
- }
58
- }
59
- setOpen((v) => !v)
60
- }
61
-
62
- return (
63
- <div className="relative" ref={ref}>
64
- <button
65
- onClick={handleToggle}
66
- className="flex items-center gap-2 h-9 px-3 text-sm text-text-primary hover:bg-ui-background transition-colors"
67
- >
68
- {org.logo ? (
69
- <img src={org.logo} alt={org.name} className="h-5 w-5 object-cover flex-shrink-0" />
70
- ) : (
71
- <div className="h-5 w-5 bg-interactive flex items-center justify-center text-white text-[10px] font-bold flex-shrink-0">
72
- {org.name.charAt(0).toUpperCase()}
73
- </div>
74
- )}
75
- <span className="font-medium">{org.name}</span>
76
- <ChevronDown className={cn("h-3 w-3 text-text-secondary transition-transform duration-150", open && "rotate-180")} />
77
- </button>
78
-
79
- {open && (
80
- <div className="absolute left-1/2 -translate-x-1/2 top-full mt-1 bg-white border border-ui-border shadow-lg z-[100] w-56 max-h-[280px] overflow-y-auto">
81
- {loading ? (
82
- <div className="px-4 py-3 text-sm text-text-secondary">Chargement…</div>
83
- ) : projects.length === 0 ? (
84
- <div className="px-4 py-3 text-sm text-text-secondary">Aucun projet disponible</div>
85
- ) : (
86
- projects.map((project) => {
87
- const isActive = currentProject === project.id
88
- return (
89
- <button
90
- key={project.id}
91
- onClick={() => { onProjectChange(project.id); setOpen(false) }}
92
- className={cn(
93
- "flex items-center justify-between w-full px-4 py-2.5 text-left transition-colors",
94
- isActive
95
- ? "bg-interactive/10"
96
- : "hover:bg-ui-background"
97
- )}
98
- >
99
- <div className="flex flex-col min-w-0">
100
- <span className={cn(
101
- "text-sm font-medium truncate",
102
- isActive ? "text-interactive" : "text-text-primary"
103
- )}>{project.name}</span>
104
- <span className="text-xs text-text-secondary truncate">{project.id}</span>
105
- </div>
106
- {isActive ? (
107
- <div className="h-5 w-5 bg-[#0f62fe] flex items-center justify-center flex-shrink-0 ml-2">
108
- <svg className="h-3 w-3 text-white" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
109
- <polyline points="2,6 5,9 10,3" />
110
- </svg>
111
- </div>
112
- ) : (
113
- <ChevronRight className="h-3.5 w-3.5 text-text-secondary flex-shrink-0 ml-2" />
114
- )}
115
- </button>
116
- )
117
- })
118
- )}
119
- </div>
120
- )}
121
- </div>
122
- )
123
- }
124
-
125
- // ─── Layout ──────────────────────────────────────────────────────────────────
126
-
127
- interface LayoutContainerProps {
128
- main_base_url: string
129
- children: React.ReactNode
130
- sidebarMenus?: SidebarMenus
131
- user?: { profile?: { email?: string; preferred_username?: string } } | null
132
- onSignOut?: () => void
133
- mode?: SidebarMode
134
- userMenuConfig?: UserMenuConfig
135
- fetchMenus?: () => Promise<MenuApiResponse>
136
- getSidebarMode?: (pathname: string, params: Record<string, string>, searchParams: URLSearchParams) => SidebarMode
137
- getCurrentMenu?: (pathname: string, searchParams: URLSearchParams) => string
138
- getCurrentMenuItem?: (pathname: string, searchParams: URLSearchParams) => string
139
- loadOrg?: () => Promise<OrgInfo>
140
- loadProjects?: () => Promise<Project[]>
141
- currentProject?: string | null
142
- onProjectChange?: (id: string) => void
143
- }
144
-
145
- function LayoutContent({
146
- children,
147
- sidebarMenus = {},
148
- user,
149
- onSignOut,
150
- mode = 'expanded',
151
- userMenuConfig,
152
- main_base_url,
153
- fetchMenus,
154
- getSidebarMode,
155
- getCurrentMenu,
156
- getCurrentMenuItem,
157
- loadOrg,
158
- loadProjects,
159
- currentProject,
160
- onProjectChange,
161
- }: LayoutContainerProps) {
162
- const pathname = usePathname()
163
- const searchParams = useSearchParams()
164
- const params = useParams()
165
- const { setOpen } = useSidebar()
166
- const isMobile = useIsMobile()
167
- const [isMainSidebarOpen, setIsMainSidebarOpen] = useState(false)
168
- const [currentMenu, setCurrentMenu] = useState<string>("overview")
169
-
170
- const effectiveMode = getSidebarMode ? getSidebarMode(pathname, params as Record<string, string>, searchParams) : mode
171
- const isMinimized = effectiveMode === 'minimized'
172
- const isHidden = effectiveMode === 'hidden' || isMobile
173
-
174
- const [mainMenuItems, setMainMenuItems] = useState<MainMenuItem[]>([])
175
- const [fetchedSidebarMenus, setFetchedSidebarMenus] = useState<SidebarMenus>({})
176
- const [sectionLabels, setSectionLabels] = useState<Record<string, string>>({})
177
- const [fetchedUserMenu, setFetchedUserMenu] = useState<Record<string, { id: string; label: string; href: string; icon: string }[]>>({})
178
-
179
- useEffect(() => {
180
- if (!fetchMenus) return
181
- fetchMenus().then((response) => {
182
- if (!response.success) return
183
- const { main, subMenus, userMenu: apiUserMenu } = response.data
184
-
185
- setMainMenuItems(
186
- main.map((item) => ({
187
- id: item.id,
188
- label: item.label,
189
- icon: resolveIcon(item.icon),
190
- }))
191
- )
192
-
193
- const menus: SidebarMenus = {}
194
- const labels: Record<string, string> = {}
195
- Object.entries(subMenus).forEach(([key, subMenu]) => {
196
- menus[key] = subMenu.items.map((item) => ({
197
- id: item.id,
198
- name: item.label,
199
- href: item.href,
200
- icon: resolveIcon(item.icon),
201
- }))
202
- labels[key] = subMenu.header
203
- })
204
- setFetchedSidebarMenus(menus)
205
- setSectionLabels(labels)
206
-
207
- if (apiUserMenu) {
208
- const userMenuMap: Record<string, { id: string; label: string; href: string; icon: string }[]> = {}
209
- Object.entries(apiUserMenu).forEach(([key, subMenu]) => {
210
- userMenuMap[key] = subMenu.items.map((item) => ({
211
- id: item.id,
212
- label: item.label,
213
- href: item.href,
214
- icon: item.icon,
215
- }))
216
- })
217
- setFetchedUserMenu(userMenuMap)
218
- }
219
- })
220
- // eslint-disable-next-line react-hooks/exhaustive-deps
221
- }, [])
222
-
223
- const effectiveSidebarMenus: SidebarMenus = { ...fetchedSidebarMenus, ...sidebarMenus }
224
-
225
- // UserMenuConfig : priorité API (context-sensitive par currentMenu), sinon prop
226
- const effectiveUserMenuConfig: UserMenuConfig | undefined =
227
- fetchedUserMenu[currentMenu]?.length
228
- ? {
229
- items: fetchedUserMenu[currentMenu].map((item) => ({
230
- id: item.id,
231
- label: item.label,
232
- href: item.href,
233
- icon: resolveIcon(item.icon),
234
- })),
235
- }
236
- : userMenuConfig
237
-
238
- useEffect(() => {
239
- if (isMobile) setIsMainSidebarOpen(false)
240
- }, [pathname, isMobile])
241
-
242
- useEffect(() => {
243
- const contextualMenu = getCurrentMenu ? getCurrentMenu(pathname, searchParams) : getMenuFromPath(pathname)
244
- setCurrentMenu(contextualMenu)
245
- }, [pathname, searchParams, getCurrentMenu])
246
-
247
- const handleMainSidebarToggle = () => setIsMainSidebarOpen((v) => !v)
248
- const handleMenuSelect = (menu: string) => setCurrentMenu(menu)
249
- const handleSecondarySidebarOpen = () => setOpen(true)
250
-
251
- return (
252
- <div className="flex h-screen w-full bg-white overflow-visible">
253
- {!isMinimized && !isHidden && (
254
- <Sidebar
255
- currentMenu={currentMenu}
256
- onMainMenuToggle={handleMainSidebarToggle}
257
- sidebarMenus={effectiveSidebarMenus}
258
- main_base_url={main_base_url}
259
- sectionLabels={sectionLabels}
260
- getCurrentMenuItem={getCurrentMenuItem}
261
- loadOrg={loadOrg}
262
- />
263
- )}
264
-
265
- <MainSidebar
266
- main_base_url={main_base_url}
267
- isOpen={isMainSidebarOpen}
268
- onToggle={handleMainSidebarToggle}
269
- onMenuSelect={handleMenuSelect}
270
- currentMenu={currentMenu}
271
- onSecondarySidebarOpen={handleSecondarySidebarOpen}
272
- mode={isHidden ? "expanded" : mode}
273
- sidebarMenus={effectiveSidebarMenus}
274
- mainMenuItems={mainMenuItems}
275
- />
276
-
277
- <div className="flex-1 flex flex-col min-w-0">
278
- <header className="h-14 bg-gray-50 border-b border-ui-border flex-shrink-0">
279
- <div className="h-full px-4 md:px-6 flex items-center justify-between">
280
- {/* Gauche : bouton menu (mobile/hidden) */}
281
- {isHidden ? (
282
- <Button
283
- variant="ghost"
284
- size="sm"
285
- onClick={handleMainSidebarToggle}
286
- className="h-8 w-8 p-0 hover:bg-ui-background text-text-secondary"
287
- title="Ouvrir le menu"
288
- >
289
- <Menu className="h-5 w-5" />
290
- </Button>
291
- ) : (
292
- <div />
293
- )}
294
-
295
- {/* Droite : dropdown organisation + user menu */}
296
- <div className="flex items-center gap-1">
297
- {loadOrg && loadProjects && onProjectChange && (
298
- <OrgProjectDropdown
299
- loadOrg={loadOrg}
300
- loadProjects={loadProjects}
301
- currentProject={currentProject}
302
- onProjectChange={onProjectChange}
303
- />
304
- )}
305
- <UserMenu
306
- username={user?.profile?.email || user?.profile?.preferred_username}
307
- onSignOut={onSignOut || (() => {})}
308
- menuConfig={effectiveUserMenuConfig}
309
- />
310
- </div>
311
- </div>
312
- </header>
313
-
314
- <main className="flex-1 overflow-auto">
315
- {children}
316
- </main>
317
- </div>
318
- </div>
319
- )
320
- }
321
-
322
- export function LayoutContainer(props: LayoutContainerProps) {
323
- return (
324
- <SidebarProvider defaultOpen={true}>
325
- <LayoutContent {...props} />
326
- </SidebarProvider>
327
- )
328
- }
1
+ "use client"
2
+
3
+ import { useState, useEffect } from "react"
4
+ import { usePathname, useSearchParams, useParams } from "next/navigation"
5
+ import { MainSidebar, type SidebarMode } from "./sidebar/main-sidebar"
6
+ import { Sidebar, SidebarProvider, useSidebar } from "./sidebar/sidebar"
7
+ import { type SidebarMenus, type MainMenuItem, resolveIcon } from "./sidebar/data"
8
+ import { Button } from "../ui"
9
+ import { getMenuFromPath } from "../../lib/menu-utils"
10
+ import { useIsMobile } from "../../hooks/use-mobile"
11
+ import { Menu } from "lucide-react"
12
+ import { UserPanel } from "./user-panel"
13
+ import { useMenuContext } from "../../context/menu-context"
14
+
15
+ // ─── Layout ──────────────────────────────────────────────────────────────────
16
+
17
+ export interface LayoutContainerProps {
18
+ main_base_url: string
19
+ children: React.ReactNode
20
+ sidebarMenus?: SidebarMenus
21
+ mode?: SidebarMode
22
+ getSidebarMode?: (pathname: string, params: Record<string, string>, searchParams: URLSearchParams) => SidebarMode
23
+ getCurrentMenu?: (pathname: string, searchParams: URLSearchParams) => string
24
+ getCurrentMenuItem?: (pathname: string, searchParams: URLSearchParams) => string
25
+ }
26
+
27
+ function LayoutContent({
28
+ children,
29
+ sidebarMenus = {},
30
+ mode = 'expanded',
31
+ main_base_url,
32
+ getSidebarMode,
33
+ getCurrentMenu,
34
+ getCurrentMenuItem,
35
+ }: LayoutContainerProps) {
36
+ const { fetchMenus } = useMenuContext()
37
+ const pathname = usePathname()
38
+ const searchParams = useSearchParams()
39
+ const params = useParams()
40
+ const { setOpen } = useSidebar()
41
+ const isMobile = useIsMobile()
42
+ const [isMainSidebarOpen, setIsMainSidebarOpen] = useState(false)
43
+ const [currentMenu, setCurrentMenu] = useState<string>("overview")
44
+
45
+ const effectiveMode = getSidebarMode ? getSidebarMode(pathname, params as Record<string, string>, searchParams) : mode
46
+ const isMinimized = effectiveMode === 'minimized'
47
+ const isHidden = effectiveMode === 'hidden' || isMobile
48
+
49
+ const [mainMenuItems, setMainMenuItems] = useState<MainMenuItem[]>([])
50
+ const [fetchedSidebarMenus, setFetchedSidebarMenus] = useState<SidebarMenus>({})
51
+ const [sectionLabels, setSectionLabels] = useState<Record<string, string>>({})
52
+
53
+ useEffect(() => {
54
+ if (!fetchMenus) return
55
+ fetchMenus().then((response) => {
56
+ if (!response.success) return
57
+ const { main, subMenus } = response.data
58
+
59
+ setMainMenuItems(
60
+ main.map((item) => ({
61
+ id: item.id,
62
+ label: item.label,
63
+ icon: resolveIcon(item.icon),
64
+ }))
65
+ )
66
+
67
+ const menus: SidebarMenus = {}
68
+ const labels: Record<string, string> = {}
69
+ Object.entries(subMenus).forEach(([key, subMenu]) => {
70
+ menus[key] = subMenu.items.map((item) => ({
71
+ id: item.id,
72
+ name: item.label,
73
+ href: item.href,
74
+ icon: resolveIcon(item.icon),
75
+ }))
76
+ labels[key] = subMenu.header
77
+ })
78
+ setFetchedSidebarMenus(menus)
79
+ setSectionLabels(labels)
80
+ })
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [])
83
+
84
+ const effectiveSidebarMenus: SidebarMenus = { ...fetchedSidebarMenus, ...sidebarMenus }
85
+
86
+ useEffect(() => {
87
+ if (isMobile) setIsMainSidebarOpen(false)
88
+ }, [pathname, isMobile])
89
+
90
+ useEffect(() => {
91
+ const contextualMenu = getCurrentMenu ? getCurrentMenu(pathname, searchParams) : getMenuFromPath(pathname)
92
+ setCurrentMenu(contextualMenu)
93
+ }, [pathname, searchParams, getCurrentMenu])
94
+
95
+ const handleMainSidebarToggle = () => setIsMainSidebarOpen((v) => !v)
96
+ const handleMenuSelect = (menu: string) => setCurrentMenu(menu)
97
+ const handleSecondarySidebarOpen = () => setOpen(true)
98
+
99
+ return (
100
+ <div className="flex h-screen w-full bg-white overflow-visible">
101
+ {!isMinimized && !isHidden && (
102
+ <Sidebar
103
+ currentMenu={currentMenu}
104
+ onMainMenuToggle={handleMainSidebarToggle}
105
+ sidebarMenus={effectiveSidebarMenus}
106
+ main_base_url={main_base_url}
107
+ sectionLabels={sectionLabels}
108
+ getCurrentMenuItem={getCurrentMenuItem}
109
+ />
110
+ )}
111
+
112
+ <MainSidebar
113
+ main_base_url={main_base_url}
114
+ isOpen={isMainSidebarOpen}
115
+ onToggle={handleMainSidebarToggle}
116
+ onMenuSelect={handleMenuSelect}
117
+ currentMenu={currentMenu}
118
+ onSecondarySidebarOpen={handleSecondarySidebarOpen}
119
+ mode={isHidden ? "expanded" : mode}
120
+ sidebarMenus={effectiveSidebarMenus}
121
+ mainMenuItems={mainMenuItems}
122
+ />
123
+
124
+ <div className="flex-1 flex flex-col min-w-0">
125
+ <header className="h-14 bg-gray-50 border-b border-ui-border flex-shrink-0">
126
+ <div className="h-full px-4 md:px-6 flex items-center justify-between">
127
+ {isHidden ? (
128
+ <Button
129
+ variant="ghost"
130
+ size="sm"
131
+ onClick={handleMainSidebarToggle}
132
+ className="h-8 w-8 p-0 hover:bg-ui-background text-text-secondary"
133
+ title="Ouvrir le menu"
134
+ >
135
+ <Menu className="h-5 w-5" />
136
+ </Button>
137
+ ) : (
138
+ <div />
139
+ )}
140
+
141
+ <UserPanel main_base_url={main_base_url} />
142
+ </div>
143
+ </header>
144
+
145
+ <main className="flex-1 overflow-auto">
146
+ {children}
147
+ </main>
148
+ </div>
149
+ </div>
150
+ )
151
+ }
152
+
153
+ export function LayoutContainer(props: LayoutContainerProps) {
154
+ return (
155
+ <SidebarProvider defaultOpen={true}>
156
+ <LayoutContent {...props} />
157
+ </SidebarProvider>
158
+ )
159
+ }
@@ -18,15 +18,9 @@ import {
18
18
  TooltipProvider,
19
19
  TooltipTrigger,
20
20
  } from "../../ui/tooltip"
21
- import {
22
- Settings,
23
- Menu,
24
- User,
25
- ChevronUp,
26
- ChevronRight,
27
- } from "lucide-react"
21
+ import { Menu } from "lucide-react"
28
22
  import { Logo } from "../../ui/logo"
29
- import { type SidebarMenus, type SubMenuItem, type OrgInfo } from "./data"
23
+ import { type SidebarMenus, type SubMenuItem } from "./data"
30
24
  import { Skeleton } from "../skeleton"
31
25
 
32
26
  const SIDEBAR_COOKIE_NAME = "sidebar:state"
@@ -173,42 +167,14 @@ interface SidebarProps {
173
167
  main_base_url?: string
174
168
  sectionLabels?: Record<string, string>
175
169
  getCurrentMenuItem?: (pathname: string, searchParams: URLSearchParams) => string
176
- loadOrg?: () => Promise<OrgInfo>
177
170
  }
178
171
 
179
172
 
180
173
 
181
- function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem, loadOrg }: SidebarProps = {}) {
174
+ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem }: SidebarProps = {}) {
182
175
  const pathname = usePathname()
183
176
  const searchParams = useSearchParams()
184
177
  const { state } = useSidebar()
185
- const [settingsOpen, setSettingsOpen] = React.useState(false)
186
- const settingsRef = React.useRef<HTMLDivElement>(null)
187
- const [orgData, setOrgData] = React.useState<OrgInfo | null>(null)
188
- const [orgLoading, setOrgLoading] = React.useState(false)
189
-
190
- // Load org lazily when settings panel opens
191
- React.useEffect(() => {
192
- if (settingsOpen && loadOrg && !orgData && !orgLoading) {
193
- setOrgLoading(true)
194
- loadOrg()
195
- .then((data) => { setOrgData(data); setOrgLoading(false) })
196
- .catch(() => setOrgLoading(false))
197
- }
198
- }, [settingsOpen, loadOrg, orgData, orgLoading])
199
-
200
- // Close dropdown on click outside
201
- React.useEffect(() => {
202
- function handleClickOutside(event: MouseEvent) {
203
- if (settingsRef.current && !settingsRef.current.contains(event.target as Node)) {
204
- setSettingsOpen(false)
205
- }
206
- }
207
- if (settingsOpen) {
208
- document.addEventListener("mousedown", handleClickOutside)
209
- return () => document.removeEventListener("mousedown", handleClickOutside)
210
- }
211
- }, [settingsOpen])
212
178
 
213
179
  // Micro apps: utilise 'items' comme clé standard
214
180
  // Main app: utilise currentMenu pour sélectionner le bon groupe
@@ -278,66 +244,6 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
278
244
  })}
279
245
  </nav>
280
246
 
281
- {/* Footer - Menu Paramètres */}
282
- <div className="relative border-t border-ui-border px-3 py-3" ref={settingsRef}>
283
- {/* Dropdown vers le haut */}
284
- {settingsOpen && (
285
- <div className="absolute bottom-full left-0 mb-3 bg-white border border-ui-border shadow-2xl z-50 min-w-[380px]">
286
- {/* Flèche pointant vers le bas en direction du bouton Paramètres */}
287
- <div className="absolute -bottom-[5px] left-6 w-[10px] h-[10px] bg-white border-r border-b border-ui-border rotate-45" />
288
-
289
- {/* Organisation */}
290
- <Link
291
- href={`${main_base_url}/organization`}
292
- onClick={() => setSettingsOpen(false)}
293
- className="flex items-center gap-4 px-5 py-4 hover:bg-ui-background transition-colors border-b border-ui-border no-underline"
294
- >
295
- {orgLoading ? (
296
- <div className="h-14 w-14 bg-ui-background animate-pulse flex-shrink-0" />
297
- ) : orgData?.logo ? (
298
- <img src={orgData.logo} alt={orgData.name } className="h-14 w-14 object-cover flex-shrink-0" />
299
- ) : (
300
- <div className="h-14 w-14 bg-interactive flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
301
- {orgData?.name?.charAt(0)?.toUpperCase() ?? 'O'}
302
- </div>
303
- )}
304
- <div className="flex flex-col min-w-0 flex-1">
305
- <span className="text-xs text-text-secondary uppercase tracking-wide mb-1">Organisation</span>
306
- <span className="text-base font-semibold text-text-primary truncate">
307
- {orgLoading ? '…' : (orgData?.name ?? 'Organisation')}
308
- </span>
309
- </div>
310
- <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0 opacity-40" />
311
- </Link>
312
-
313
- {/* Paramètres du compte */}
314
- <Link
315
- href={`${main_base_url}/profile`}
316
- onClick={() => setSettingsOpen(false)}
317
- className="flex items-center gap-3 px-5 py-3 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline"
318
- >
319
- <User className="h-5 w-5 flex-shrink-0" />
320
- <span className="flex-1">Paramètres du compte</span>
321
- <ChevronRight className="h-4 w-4 flex-shrink-0 opacity-40" />
322
- </Link>
323
- </div>
324
- )}
325
- <button
326
- onClick={() => setSettingsOpen(!settingsOpen)}
327
- className={cn(
328
- "flex items-center justify-between w-full px-3 py-2 text-sm rounded-none gap-x-3 transition-colors",
329
- settingsOpen
330
- ? "bg-interactive/10 text-interactive font-medium"
331
- : "text-text-secondary hover:bg-ui-background hover:text-text-primary"
332
- )}
333
- >
334
- <span className="flex items-center gap-x-3">
335
- <Settings className="h-4 w-4" />
336
- {state === "expanded" && "Paramètres"}
337
- </span>
338
- {state === "expanded" && <ChevronUp className={cn("h-4 w-4 transition-transform", settingsOpen ? "rotate-0" : "rotate-180")} />}
339
- </button>
340
- </div>
341
247
  </div>
342
248
  )
343
249
  }
@@ -0,0 +1,145 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { ChevronRight, LogOut } from "lucide-react"
5
+ import ReactAvatar from "react-avatar"
6
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
7
+ import { useUserContext } from "../../context/user-context"
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuTrigger,
12
+ } from "../ui/dropdown-menu"
13
+ import { Button } from "../ui"
14
+
15
+ const Divider = () => <div className="h-px bg-gray-200 w-full" />
16
+
17
+ interface UserPanelProps {
18
+ main_base_url: string
19
+ }
20
+
21
+ export function UserPanel({ main_base_url }: UserPanelProps) {
22
+ const [open, setOpen] = useState(false)
23
+ const { profile, currentProject, organization, logout, openBusinessUnitSwitcher } = useUserContext()
24
+
25
+ const displayName =
26
+ profile?.name || profile?.preferred_username || profile?.email || "User"
27
+
28
+ const close = () => setOpen(false)
29
+
30
+ return (
31
+ <DropdownMenu open={open} onOpenChange={setOpen}>
32
+ <DropdownMenuTrigger asChild>
33
+ <Button
34
+ variant="ghost"
35
+ size="sm"
36
+ className="flex items-center h-9 px-2"
37
+ style={{ borderRadius: 0 }}
38
+ >
39
+ <ReactAvatar
40
+ name={displayName}
41
+ src={profile?.avatar}
42
+ size="28"
43
+ round
44
+ color="#0f62fe"
45
+ textSizeRatio={2}
46
+ />
47
+ </Button>
48
+ </DropdownMenuTrigger>
49
+
50
+ <DropdownMenuContent
51
+ align="end"
52
+ sideOffset={8}
53
+ className="w-80 bg-white shadow-xl border border-gray-200 p-0 rounded-none"
54
+ >
55
+ <DropdownMenuPrimitive.Arrow width={16} height={8} style={{ fill: 'white', stroke: '#e5e7eb', strokeWidth: 1 }} />
56
+
57
+ {/* Organisation | Business Unit */}
58
+ <div className="flex items-stretch">
59
+
60
+ {/* Gauche — Organisation */}
61
+ <a
62
+ href={`${main_base_url}/organization`}
63
+ onClick={close}
64
+ className="flex items-center gap-2 flex-1 min-w-0 px-4 py-4 hover:bg-gray-50 transition-colors no-underline group"
65
+ >
66
+ <div className="flex flex-col min-w-0 flex-1">
67
+ <span className="text-[11px] text-gray-400 uppercase tracking-widest mb-1">Organisation</span>
68
+ <span className="text-sm font-semibold text-gray-900 truncate">
69
+ {organization?.name ?? '—'}
70
+ </span>
71
+ </div>
72
+ <ChevronRight className="h-4 w-4 text-gray-300 flex-shrink-0 group-hover:text-gray-500 transition-colors" />
73
+ </a>
74
+
75
+ {/* Séparateur vertical */}
76
+ <div className="flex items-center py-3">
77
+ <div className="w-px h-full bg-gray-200" />
78
+ </div>
79
+
80
+ {/* Droite — Business Unit */}
81
+ <div className="flex flex-col justify-center flex-1 min-w-0 px-4 py-4">
82
+ <span className="text-[11px] text-gray-400 uppercase tracking-widest mb-1">Business Unit</span>
83
+ <span className="text-sm font-semibold text-gray-900 truncate">
84
+ {currentProject?.name ?? '—'}
85
+ </span>
86
+ <div className="flex items-center gap-3 mt-1.5">
87
+ <button
88
+ onClick={() => { close(); openBusinessUnitSwitcher() }}
89
+ className="text-xs text-[#0f62fe] hover:underline"
90
+ >
91
+ Switch
92
+ </button>
93
+ {currentProject && organization && (
94
+ <a
95
+ href={`${main_base_url}/${organization.id}/projects/${currentProject.id}`}
96
+ onClick={close}
97
+ className="text-xs text-[#0f62fe] hover:underline"
98
+ >
99
+ Settings
100
+ </a>
101
+ )}
102
+ </div>
103
+ </div>
104
+
105
+ </div>
106
+
107
+ <Divider />
108
+
109
+ {/* User info + Logout */}
110
+ <div className="flex items-center gap-3 px-4 py-3">
111
+ <ReactAvatar
112
+ name={displayName}
113
+ src={profile?.avatar}
114
+ size="40"
115
+ round
116
+ color="#0f62fe"
117
+ textSizeRatio={2}
118
+ />
119
+ <div className="flex flex-col min-w-0 flex-1">
120
+ <span className="font-bold text-sm text-gray-900 truncate">{displayName}</span>
121
+ {profile?.email && (
122
+ <span className="text-xs text-gray-500 truncate">{profile.email}</span>
123
+ )}
124
+ <a
125
+ href={`${main_base_url}/profile`}
126
+ onClick={close}
127
+ className="text-xs text-[#0f62fe] hover:underline mt-0.5 w-fit"
128
+ >
129
+ Settings
130
+ </a>
131
+ </div>
132
+ <button
133
+ onClick={() => { close(); logout() }}
134
+ className="flex items-center gap-1.5 text-xs text-red-600 hover:text-red-700 flex-shrink-0 ml-2"
135
+ title="Logout"
136
+ >
137
+ <LogOut className="h-3.5 w-3.5" />
138
+ <span>Logout</span>
139
+ </button>
140
+ </div>
141
+
142
+ </DropdownMenuContent>
143
+ </DropdownMenu>
144
+ )
145
+ }
@@ -21,23 +21,21 @@ import {
21
21
  import { Loader2, FolderKanban } from "lucide-react"
22
22
 
23
23
  export interface Project {
24
- id: string,
24
+ id: string
25
25
  name: string
26
26
  alias?: string
27
27
  description?: string
28
28
  }
29
29
 
30
- export interface ProjectSelectorModalProps {
31
- open: boolean
32
- onOpenChange?: (open: boolean) => void
30
+ export type ProjectSelectorProps = {
33
31
  getProjects: () => Promise<Project[]>
34
32
  createProject: (name: string) => Promise<Project>
35
- storage: {
36
- getItem: (key: string) => string | null
37
- setItem: (key: string, value: string) => void
38
- }
39
- storageKey: string
40
- doInit: () => void | Promise<void>
33
+ doInit: (id: string) => void | Promise<void>
34
+ }
35
+
36
+ export interface ProjectSelectorModalProps extends ProjectSelectorProps {
37
+ open: boolean
38
+ onOpenChange?: (open: boolean) => void
41
39
  title?: string
42
40
  description?: string
43
41
  }
@@ -45,13 +43,11 @@ export interface ProjectSelectorModalProps {
45
43
  export function ProjectSelectorModal({
46
44
  open,
47
45
  onOpenChange,
46
+ title = "Select Business Unit",
47
+ description = "Choose a business unit to continue. This will be used for all operations in this application.",
48
48
  getProjects,
49
49
  createProject,
50
- storage,
51
- storageKey,
52
50
  doInit,
53
- title = "Select Business Unit",
54
- description = "Choose a business unit to continue. This will be used for all operations in this application.",
55
51
  }: ProjectSelectorModalProps) {
56
52
  const [projects, setProjects] = React.useState<Project[]>([])
57
53
  const [loading, setLoading] = React.useState(true)
@@ -63,13 +59,8 @@ export function ProjectSelectorModal({
63
59
  React.useEffect(() => {
64
60
  if (open) {
65
61
  loadProjects()
66
- // Try to load current project from storage
67
- const currentProject = storage.getItem(storageKey)
68
- if (currentProject) {
69
- setSelectedProject(currentProject)
70
- }
71
62
  }
72
- }, [open, storage, storageKey])
63
+ }, [open])
73
64
 
74
65
  const loadProjects = async () => {
75
66
  setLoading(true)
@@ -107,11 +98,8 @@ export function ProjectSelectorModal({
107
98
  // Create the new project
108
99
  const newProject = await createProject(newProjectName.trim())
109
100
 
110
- // Save to storage
111
- storage.setItem(storageKey, newProject.name)
112
-
113
101
  // Call doInit
114
- await Promise.resolve(doInit())
102
+ await Promise.resolve(doInit(newProject.id))
115
103
 
116
104
  // Close modal
117
105
  onOpenChange?.(false)
@@ -132,11 +120,8 @@ export function ProjectSelectorModal({
132
120
  setError(null)
133
121
 
134
122
  try {
135
- // Save to storage
136
- storage.setItem(storageKey, selectedProject)
137
-
138
123
  // Call doInit
139
- await Promise.resolve(doInit())
124
+ await Promise.resolve(doInit(selectedProject))
140
125
 
141
126
  // Close modal
142
127
  onOpenChange?.(false)
package/context/index.tsx CHANGED
@@ -36,4 +36,7 @@ type workflowEditContext = {
36
36
  stepName?: string;
37
37
  };
38
38
 
39
- export const WorkflowEditContext = React.createContext<workflowEditContext>({});
39
+ export const WorkflowEditContext = React.createContext<workflowEditContext>({})
40
+
41
+ export { useUserContext, UserContextProvider, type UserProfile } from './user-context'
42
+ export { useMenuContext, MenuContextProvider } from './menu-context';
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import React, { createContext, useContext } from "react"
4
+ import type { MenuApiResponse } from "../components/layout/sidebar/data"
5
+
6
+ interface MenuContextValue {
7
+ fetchMenus: () => Promise<MenuApiResponse>
8
+ }
9
+
10
+ const MenuContext = createContext<MenuContextValue | null>(null)
11
+
12
+ export function useMenuContext(): MenuContextValue {
13
+ const ctx = useContext(MenuContext)
14
+ if (!ctx) throw new Error("useMenuContext must be used within MenuContextProvider")
15
+ return ctx
16
+ }
17
+
18
+ interface MenuContextProviderProps {
19
+ children: React.ReactNode
20
+ fetchMenus: () => Promise<MenuApiResponse>
21
+ }
22
+
23
+ export function MenuContextProvider({ children, fetchMenus }: MenuContextProviderProps) {
24
+ return (
25
+ <MenuContext.Provider value={{ fetchMenus }}>
26
+ {children}
27
+ </MenuContext.Provider>
28
+ )
29
+ }
@@ -0,0 +1,24 @@
1
+ import { createContext, useContext } from "react"
2
+
3
+ export interface Project {
4
+ id: string
5
+ name: string
6
+ alias?: string
7
+ description?: string
8
+ }
9
+
10
+ export interface ProjectSelectorContextType {
11
+ getProjects: () => Promise<Project[]>
12
+ createProject: (name: string) => Promise<Project>
13
+ doInit: () => void | Promise<void>
14
+ }
15
+
16
+ export const ProjectSelectorContext = createContext<ProjectSelectorContextType | undefined>(undefined)
17
+
18
+ export const useProjectSelector = () => {
19
+ const context = useContext(ProjectSelectorContext)
20
+ if (!context) {
21
+ throw new Error('useProjectSelector must be used within a ProjectSelectorProvider')
22
+ }
23
+ return context
24
+ }
@@ -0,0 +1,61 @@
1
+ "use client"
2
+
3
+ import React, { createContext, useContext } from "react"
4
+ import type { OrgInfo, Project } from "../components/layout/sidebar/data"
5
+
6
+ export interface UserProfile {
7
+ email?: string
8
+ preferred_username?: string
9
+ name?: string
10
+ avatar?: string
11
+ }
12
+
13
+ interface UserContextValue {
14
+ profile: UserProfile | null
15
+ currentProject: Project | null
16
+ organization: OrgInfo | null
17
+ logout: () => void
18
+ switchBusinessUnit: (id: string) => void
19
+ openBusinessUnitSwitcher: () => void
20
+ }
21
+
22
+ const UserContext = createContext<UserContextValue | null>(null)
23
+
24
+ export function useUserContext(): UserContextValue {
25
+ const ctx = useContext(UserContext)
26
+ if (!ctx) throw new Error("useUserContext must be used within UserContextProvider")
27
+ return ctx
28
+ }
29
+
30
+ interface UserContextProviderProps {
31
+ children: React.ReactNode
32
+ profile?: UserProfile | null
33
+ currentProject?: Project | null
34
+ organization?: OrgInfo | null
35
+ onSignOut?: () => void
36
+ onProjectChange?: (id: string) => void
37
+ onOpenBusinessUnitSwitcher?: () => void
38
+ }
39
+
40
+ export function UserContextProvider({
41
+ children,
42
+ profile,
43
+ currentProject = null,
44
+ organization = null,
45
+ onSignOut,
46
+ onProjectChange,
47
+ onOpenBusinessUnitSwitcher,
48
+ }: UserContextProviderProps) {
49
+ return (
50
+ <UserContext.Provider value={{
51
+ profile: profile ?? null,
52
+ currentProject,
53
+ organization,
54
+ logout: onSignOut ?? (() => {}),
55
+ switchBusinessUnit: onProjectChange ?? (() => {}),
56
+ openBusinessUnitSwitcher: onOpenBusinessUnitSwitcher ?? (() => {}),
57
+ }}>
58
+ {children}
59
+ </UserContext.Provider>
60
+ )
61
+ }
@@ -6,16 +6,8 @@ export const interceptors: Interceptors = {
6
6
  return response;
7
7
  },
8
8
  onResponseError: (error: any) => {
9
- if (error.response?.status) {
10
- const status = error.response.status;
11
-
12
- if (typeof window !== 'undefined') {
13
- switch (status) {
14
- case 401:
15
- window.location.href = '/login';
16
- break;
17
- }
18
- }
9
+ if (error.response?.status === 401 && typeof window !== 'undefined') {
10
+ window.dispatchEvent(new CustomEvent('http-unauthorized'));
19
11
  }
20
12
  return Promise.reject(error);
21
13
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orsetra/shared-ui",
3
- "version": "1.2.4",
3
+ "version": "1.3.1",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",