@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.
- package/components/layout/layout-container.tsx +159 -328
- package/components/layout/sidebar/sidebar.tsx +6 -19
- package/components/layout/user-panel.tsx +124 -0
- package/context/index.tsx +4 -1
- package/context/menu-context.tsx +29 -0
- package/context/user-context.tsx +57 -0
- package/lib/interceptors.ts +2 -10
- package/package.json +1 -1
|
@@ -1,328 +1,159 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect
|
|
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,
|
|
8
|
-
import {
|
|
9
|
-
import { getMenuFromPath } from "../../lib/menu-utils"
|
|
10
|
-
import { useIsMobile } from "../../hooks/use-mobile"
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
+
}
|
package/lib/interceptors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|