@orsetra/shared-ui 1.2.4 → 1.3.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,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
+ }
@@ -26,8 +26,9 @@ import {
26
26
  ChevronRight,
27
27
  } from "lucide-react"
28
28
  import { Logo } from "../../ui/logo"
29
- import { type SidebarMenus, type SubMenuItem, type OrgInfo } from "./data"
29
+ import { type SidebarMenus, type SubMenuItem } from "./data"
30
30
  import { Skeleton } from "../skeleton"
31
+ import { useUserContext } from "../../../context/user-context"
31
32
 
32
33
  const SIDEBAR_COOKIE_NAME = "sidebar:state"
33
34
  const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -173,29 +174,17 @@ interface SidebarProps {
173
174
  main_base_url?: string
174
175
  sectionLabels?: Record<string, string>
175
176
  getCurrentMenuItem?: (pathname: string, searchParams: URLSearchParams) => string
176
- loadOrg?: () => Promise<OrgInfo>
177
177
  }
178
178
 
179
179
 
180
180
 
181
- function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem, loadOrg }: SidebarProps = {}) {
181
+ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem }: SidebarProps = {}) {
182
182
  const pathname = usePathname()
183
183
  const searchParams = useSearchParams()
184
184
  const { state } = useSidebar()
185
185
  const [settingsOpen, setSettingsOpen] = React.useState(false)
186
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])
187
+ const { organization: orgData } = useUserContext()
199
188
 
200
189
  // Close dropdown on click outside
201
190
  React.useEffect(() => {
@@ -292,9 +281,7 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
292
281
  onClick={() => setSettingsOpen(false)}
293
282
  className="flex items-center gap-4 px-5 py-4 hover:bg-ui-background transition-colors border-b border-ui-border no-underline"
294
283
  >
295
- {orgLoading ? (
296
- <div className="h-14 w-14 bg-ui-background animate-pulse flex-shrink-0" />
297
- ) : orgData?.logo ? (
284
+ {orgData?.logo ? (
298
285
  <img src={orgData.logo} alt={orgData.name } className="h-14 w-14 object-cover flex-shrink-0" />
299
286
  ) : (
300
287
  <div className="h-14 w-14 bg-interactive flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
@@ -304,7 +291,7 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
304
291
  <div className="flex flex-col min-w-0 flex-1">
305
292
  <span className="text-xs text-text-secondary uppercase tracking-wide mb-1">Organisation</span>
306
293
  <span className="text-base font-semibold text-text-primary truncate">
307
- {orgLoading ? '…' : (orgData?.name ?? 'Organisation')}
294
+ {orgData?.name ?? 'Organisation'}
308
295
  </span>
309
296
  </div>
310
297
  <ChevronRight className="h-4 w-4 text-text-secondary flex-shrink-0 opacity-40" />
@@ -0,0 +1,124 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { ChevronDown, Settings, LogOut } from "lucide-react"
5
+ import ReactAvatar from "react-avatar"
6
+ import { useUserContext } from "../../context/user-context"
7
+ import {
8
+ Sheet,
9
+ SheetContent,
10
+ SheetTitle,
11
+ SheetTrigger,
12
+ } from "../ui/sheet"
13
+ import { Button } from "../ui"
14
+ import { cn } from "../../lib/utils"
15
+
16
+ interface UserPanelProps {
17
+ main_base_url: string
18
+ }
19
+
20
+ export function UserPanel({ main_base_url }: UserPanelProps) {
21
+ const [open, setOpen] = useState(false)
22
+ const { profile, currentProject, organization, logout } = useUserContext()
23
+
24
+ const displayName =
25
+ profile?.name || profile?.preferred_username || profile?.email || "User"
26
+
27
+ return (
28
+ <Sheet open={open} onOpenChange={setOpen}>
29
+ <SheetTrigger asChild>
30
+ <Button
31
+ variant="ghost"
32
+ size="sm"
33
+ className="flex items-center gap-1 h-9 px-2"
34
+ style={{ borderRadius: 0 }}
35
+ >
36
+ <ReactAvatar
37
+ name={displayName}
38
+ src={profile?.avatar}
39
+ size="28"
40
+ round
41
+ color="#0f62fe"
42
+ textSizeRatio={2}
43
+ />
44
+ <ChevronDown
45
+ className={cn(
46
+ "h-4 w-4 text-gray-500 transition-transform duration-150",
47
+ open && "rotate-180"
48
+ )}
49
+ />
50
+ </Button>
51
+ </SheetTrigger>
52
+
53
+ <SheetContent side="right" className="w-72 p-0 flex flex-col">
54
+ <SheetTitle className="sr-only">Menu utilisateur</SheetTitle>
55
+
56
+ {/* Header — Business Unit */}
57
+ <div className="flex items-center justify-between px-4 py-3 border-b border-ui-border">
58
+ <span className="font-semibold text-sm text-text-primary truncate">
59
+ {currentProject?.name ?? "—"}
60
+ </span>
61
+ {currentProject && organization && (
62
+ <a
63
+ href={`${main_base_url}/${organization.id}/projects/${currentProject.id}`}
64
+ className="text-text-secondary hover:text-text-primary ml-2 flex-shrink-0"
65
+ onClick={() => setOpen(false)}
66
+ title="Paramètres du projet"
67
+ >
68
+ <Settings className="h-4 w-4" />
69
+ </a>
70
+ )}
71
+ </div>
72
+
73
+ {/* User info */}
74
+ <div className="flex items-start gap-3 px-4 py-5">
75
+ <ReactAvatar
76
+ name={displayName}
77
+ src={profile?.avatar}
78
+ size="48"
79
+ round
80
+ color="#0f62fe"
81
+ textSizeRatio={2}
82
+ />
83
+ <div className="flex flex-col min-w-0 justify-center">
84
+ <span className="font-bold text-sm text-text-primary truncate">
85
+ {displayName}
86
+ </span>
87
+ {profile?.email && (
88
+ <span className="text-xs text-text-secondary truncate">
89
+ {profile.email}
90
+ </span>
91
+ )}
92
+ </div>
93
+ </div>
94
+
95
+ <div className="border-t border-ui-border" />
96
+
97
+ {/* Account settings */}
98
+ <div className="px-4 py-3">
99
+ <a
100
+ href={`${main_base_url}/profile`}
101
+ className="text-sm text-text-primary hover:underline"
102
+ onClick={() => setOpen(false)}
103
+ >
104
+ Paramètres du compte
105
+ </a>
106
+ </div>
107
+
108
+ {/* Spacer */}
109
+ <div className="flex-1" />
110
+
111
+ {/* Footer — Logout */}
112
+ <div className="border-t border-ui-border px-4 py-3">
113
+ <button
114
+ onClick={() => { setOpen(false); logout() }}
115
+ className="flex items-center gap-2 text-sm text-red-600 hover:text-red-700 w-full"
116
+ >
117
+ <LogOut className="h-4 w-4" />
118
+ Logout
119
+ </button>
120
+ </div>
121
+ </SheetContent>
122
+ </Sheet>
123
+ )
124
+ }
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,57 @@
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
+ }
20
+
21
+ const UserContext = createContext<UserContextValue | null>(null)
22
+
23
+ export function useUserContext(): UserContextValue {
24
+ const ctx = useContext(UserContext)
25
+ if (!ctx) throw new Error("useUserContext must be used within UserContextProvider")
26
+ return ctx
27
+ }
28
+
29
+ interface UserContextProviderProps {
30
+ children: React.ReactNode
31
+ profile?: UserProfile | null
32
+ currentProject?: Project | null
33
+ organization?: OrgInfo | null
34
+ onSignOut?: () => void
35
+ onProjectChange?: (id: string) => void
36
+ }
37
+
38
+ export function UserContextProvider({
39
+ children,
40
+ profile,
41
+ currentProject = null,
42
+ organization = null,
43
+ onSignOut,
44
+ onProjectChange,
45
+ }: UserContextProviderProps) {
46
+ return (
47
+ <UserContext.Provider value={{
48
+ profile: profile ?? null,
49
+ currentProject,
50
+ organization,
51
+ logout: onSignOut ?? (() => {}),
52
+ switchBusinessUnit: onProjectChange ?? (() => {}),
53
+ }}>
54
+ {children}
55
+ </UserContext.Provider>
56
+ )
57
+ }
@@ -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.0",
4
4
  "description": "Shared UI components for Orsetra platform",
5
5
  "main": "./index.ts",
6
6
  "types": "./index.ts",