@orsetra/shared-ui 1.1.29 → 1.1.32
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,6 @@
|
|
|
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 } from './sidebar/data'
|
|
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'
|
|
4
4
|
export {
|
|
5
5
|
Sidebar,
|
|
6
6
|
SidebarContent,
|
|
@@ -1,14 +1,114 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from "react"
|
|
3
|
+
import { useState, useEffect, useRef } from "react"
|
|
4
4
|
import { usePathname, useSearchParams, useParams } from "next/navigation"
|
|
5
5
|
import { MainSidebar, type SidebarMode } from "./sidebar/main-sidebar"
|
|
6
6
|
import { Sidebar, SidebarProvider, useSidebar } from "./sidebar/sidebar"
|
|
7
|
-
import { type SidebarMenus, type MainMenuItem, type MenuApiResponse, resolveIcon } from "./sidebar/data"
|
|
7
|
+
import { type SidebarMenus, type MainMenuItem, type MenuApiResponse, type OrgInfo, type Project, resolveIcon } from "./sidebar/data"
|
|
8
8
|
import { UserMenu, Button, type UserMenuConfig } from "../ui"
|
|
9
9
|
import { getMenuFromPath } from "../../lib/menu-utils"
|
|
10
10
|
import { useIsMobile } from "../../hooks/use-mobile"
|
|
11
|
-
import {
|
|
11
|
+
import { cn } from "../../lib/utils"
|
|
12
|
+
import { Menu, ChevronDown, Check } 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.nom} 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.nom.charAt(0).toUpperCase()}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
<span className="font-medium">{org.nom}</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-0 top-full bg-white border border-ui-border shadow-lg z-[100] min-w-[220px] 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 gap-3 w-full px-4 py-2 text-sm text-left transition-colors border-l-4",
|
|
94
|
+
isActive
|
|
95
|
+
? "bg-interactive/10 text-interactive font-medium border-interactive"
|
|
96
|
+
: "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<Check className={cn("h-4 w-4 flex-shrink-0", !isActive && "invisible")} />
|
|
100
|
+
{project.name}
|
|
101
|
+
</button>
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Layout ──────────────────────────────────────────────────────────────────
|
|
12
112
|
|
|
13
113
|
interface LayoutContainerProps {
|
|
14
114
|
main_base_url: string
|
|
@@ -19,12 +119,32 @@ interface LayoutContainerProps {
|
|
|
19
119
|
mode?: SidebarMode
|
|
20
120
|
userMenuConfig?: UserMenuConfig
|
|
21
121
|
fetchMenus?: () => Promise<MenuApiResponse>
|
|
22
|
-
getSidebarMode?: (pathname: string,params: Record<string, string>, searchParams: URLSearchParams) => SidebarMode
|
|
122
|
+
getSidebarMode?: (pathname: string, params: Record<string, string>, searchParams: URLSearchParams) => SidebarMode
|
|
23
123
|
getCurrentMenu?: (pathname: string, searchParams: URLSearchParams) => string
|
|
24
124
|
getCurrentMenuItem?: (pathname: string, searchParams: URLSearchParams) => string
|
|
125
|
+
loadOrg?: () => Promise<OrgInfo>
|
|
126
|
+
loadProjects?: () => Promise<Project[]>
|
|
127
|
+
currentProject?: string | null
|
|
128
|
+
onProjectChange?: (id: string) => void
|
|
25
129
|
}
|
|
26
130
|
|
|
27
|
-
function LayoutContent({
|
|
131
|
+
function LayoutContent({
|
|
132
|
+
children,
|
|
133
|
+
sidebarMenus = {},
|
|
134
|
+
user,
|
|
135
|
+
onSignOut,
|
|
136
|
+
mode = 'expanded',
|
|
137
|
+
userMenuConfig,
|
|
138
|
+
main_base_url,
|
|
139
|
+
fetchMenus,
|
|
140
|
+
getSidebarMode,
|
|
141
|
+
getCurrentMenu,
|
|
142
|
+
getCurrentMenuItem,
|
|
143
|
+
loadOrg,
|
|
144
|
+
loadProjects,
|
|
145
|
+
currentProject,
|
|
146
|
+
onProjectChange,
|
|
147
|
+
}: LayoutContainerProps) {
|
|
28
148
|
const pathname = usePathname()
|
|
29
149
|
const searchParams = useSearchParams()
|
|
30
150
|
const params = useParams()
|
|
@@ -32,23 +152,21 @@ function LayoutContent({ children, sidebarMenus = {}, user, onSignOut, mode = 'e
|
|
|
32
152
|
const isMobile = useIsMobile()
|
|
33
153
|
const [isMainSidebarOpen, setIsMainSidebarOpen] = useState(false)
|
|
34
154
|
const [currentMenu, setCurrentMenu] = useState<string>("overview")
|
|
35
|
-
|
|
36
|
-
// Use custom mode function if provided, otherwise use prop
|
|
155
|
+
|
|
37
156
|
const effectiveMode = getSidebarMode ? getSidebarMode(pathname, params as Record<string, string>, searchParams) : mode
|
|
38
157
|
const isMinimized = effectiveMode === 'minimized'
|
|
39
|
-
// Force hidden mode on mobile
|
|
40
158
|
const isHidden = effectiveMode === 'hidden' || isMobile
|
|
41
159
|
|
|
42
160
|
const [mainMenuItems, setMainMenuItems] = useState<MainMenuItem[]>([])
|
|
43
161
|
const [fetchedSidebarMenus, setFetchedSidebarMenus] = useState<SidebarMenus>({})
|
|
44
162
|
const [sectionLabels, setSectionLabels] = useState<Record<string, string>>({})
|
|
163
|
+
const [fetchedUserMenu, setFetchedUserMenu] = useState<Record<string, { id: string; label: string; href: string; icon: string }[]>>({})
|
|
45
164
|
|
|
46
|
-
// Load menus from API when fetchMenus is provided
|
|
47
165
|
useEffect(() => {
|
|
48
166
|
if (!fetchMenus) return
|
|
49
167
|
fetchMenus().then((response) => {
|
|
50
168
|
if (!response.success) return
|
|
51
|
-
const { main, subMenus } = response.data
|
|
169
|
+
const { main, subMenus, userMenu: apiUserMenu } = response.data
|
|
52
170
|
|
|
53
171
|
setMainMenuItems(
|
|
54
172
|
main.map((item) => ({
|
|
@@ -71,41 +189,53 @@ function LayoutContent({ children, sidebarMenus = {}, user, onSignOut, mode = 'e
|
|
|
71
189
|
})
|
|
72
190
|
setFetchedSidebarMenus(menus)
|
|
73
191
|
setSectionLabels(labels)
|
|
192
|
+
|
|
193
|
+
if (apiUserMenu) {
|
|
194
|
+
const userMenuMap: Record<string, { id: string; label: string; href: string; icon: string }[]> = {}
|
|
195
|
+
Object.entries(apiUserMenu).forEach(([key, subMenu]) => {
|
|
196
|
+
userMenuMap[key] = subMenu.items.map((item) => ({
|
|
197
|
+
id: item.id,
|
|
198
|
+
label: item.label,
|
|
199
|
+
href: item.href,
|
|
200
|
+
icon: item.icon,
|
|
201
|
+
}))
|
|
202
|
+
})
|
|
203
|
+
setFetchedUserMenu(userMenuMap)
|
|
204
|
+
}
|
|
74
205
|
})
|
|
75
206
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
76
207
|
}, [])
|
|
77
208
|
|
|
78
|
-
// Effective sidebarMenus: fetched provides section keys, prop provides items key (micro apps)
|
|
79
209
|
const effectiveSidebarMenus: SidebarMenus = { ...fetchedSidebarMenus, ...sidebarMenus }
|
|
80
210
|
|
|
81
|
-
//
|
|
211
|
+
// UserMenuConfig : priorité API (context-sensitive par currentMenu), sinon prop
|
|
212
|
+
const effectiveUserMenuConfig: UserMenuConfig | undefined =
|
|
213
|
+
fetchedUserMenu[currentMenu]?.length
|
|
214
|
+
? {
|
|
215
|
+
items: fetchedUserMenu[currentMenu].map((item) => ({
|
|
216
|
+
id: item.id,
|
|
217
|
+
label: item.label,
|
|
218
|
+
href: item.href,
|
|
219
|
+
icon: resolveIcon(item.icon),
|
|
220
|
+
})),
|
|
221
|
+
}
|
|
222
|
+
: userMenuConfig
|
|
223
|
+
|
|
82
224
|
useEffect(() => {
|
|
83
|
-
if (isMobile)
|
|
84
|
-
setIsMainSidebarOpen(false)
|
|
85
|
-
}
|
|
225
|
+
if (isMobile) setIsMainSidebarOpen(false)
|
|
86
226
|
}, [pathname, isMobile])
|
|
87
227
|
|
|
88
228
|
useEffect(() => {
|
|
89
|
-
// Use custom menu function if provided, otherwise use default getMenuFromPath
|
|
90
229
|
const contextualMenu = getCurrentMenu ? getCurrentMenu(pathname, searchParams) : getMenuFromPath(pathname)
|
|
91
230
|
setCurrentMenu(contextualMenu)
|
|
92
231
|
}, [pathname, searchParams, getCurrentMenu])
|
|
93
232
|
|
|
94
|
-
const handleMainSidebarToggle = () =>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const handleMenuSelect = (menu: string) => {
|
|
99
|
-
setCurrentMenu(menu)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const handleSecondarySidebarOpen = () => {
|
|
103
|
-
setOpen(true)
|
|
104
|
-
}
|
|
233
|
+
const handleMainSidebarToggle = () => setIsMainSidebarOpen((v) => !v)
|
|
234
|
+
const handleMenuSelect = (menu: string) => setCurrentMenu(menu)
|
|
235
|
+
const handleSecondarySidebarOpen = () => setOpen(true)
|
|
105
236
|
|
|
106
237
|
return (
|
|
107
|
-
<div className="flex h-screen w-full bg-white">
|
|
108
|
-
{/* Desktop sidebar - hidden on mobile (isHidden is true on mobile) */}
|
|
238
|
+
<div className="flex h-screen w-full bg-white overflow-visible">
|
|
109
239
|
{!isMinimized && !isHidden && (
|
|
110
240
|
<Sidebar
|
|
111
241
|
currentMenu={currentMenu}
|
|
@@ -114,10 +244,10 @@ function LayoutContent({ children, sidebarMenus = {}, user, onSignOut, mode = 'e
|
|
|
114
244
|
main_base_url={main_base_url}
|
|
115
245
|
sectionLabels={sectionLabels}
|
|
116
246
|
getCurrentMenuItem={getCurrentMenuItem}
|
|
247
|
+
loadOrg={loadOrg}
|
|
117
248
|
/>
|
|
118
249
|
)}
|
|
119
250
|
|
|
120
|
-
{/* MainSidebar - always available, opens as overlay when isHidden */}
|
|
121
251
|
<MainSidebar
|
|
122
252
|
main_base_url={main_base_url}
|
|
123
253
|
isOpen={isMainSidebarOpen}
|
|
@@ -133,7 +263,7 @@ function LayoutContent({ children, sidebarMenus = {}, user, onSignOut, mode = 'e
|
|
|
133
263
|
<div className="flex-1 flex flex-col min-w-0">
|
|
134
264
|
<header className="h-14 bg-gray-50 border-b border-ui-border flex-shrink-0">
|
|
135
265
|
<div className="h-full px-4 md:px-6 flex items-center justify-between">
|
|
136
|
-
{/*
|
|
266
|
+
{/* Gauche : bouton menu (mobile/hidden) */}
|
|
137
267
|
{isHidden ? (
|
|
138
268
|
<Button
|
|
139
269
|
variant="ghost"
|
|
@@ -147,11 +277,23 @@ function LayoutContent({ children, sidebarMenus = {}, user, onSignOut, mode = 'e
|
|
|
147
277
|
) : (
|
|
148
278
|
<div />
|
|
149
279
|
)}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
280
|
+
|
|
281
|
+
{/* Droite : dropdown organisation + user menu */}
|
|
282
|
+
<div className="flex items-center gap-1">
|
|
283
|
+
{loadOrg && loadProjects && onProjectChange && (
|
|
284
|
+
<OrgProjectDropdown
|
|
285
|
+
loadOrg={loadOrg}
|
|
286
|
+
loadProjects={loadProjects}
|
|
287
|
+
currentProject={currentProject}
|
|
288
|
+
onProjectChange={onProjectChange}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
<UserMenu
|
|
292
|
+
username={user?.profile?.email || user?.profile?.preferred_username}
|
|
293
|
+
onSignOut={onSignOut || (() => {})}
|
|
294
|
+
menuConfig={effectiveUserMenuConfig}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
155
297
|
</div>
|
|
156
298
|
</header>
|
|
157
299
|
|
|
@@ -42,6 +42,17 @@ export interface SidebarMenus {
|
|
|
42
42
|
[key: string]: SubMenuItem[]
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface OrgInfo {
|
|
46
|
+
nom: string
|
|
47
|
+
id: string
|
|
48
|
+
logo?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface Project {
|
|
52
|
+
id: string
|
|
53
|
+
name: string
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
// API response types
|
|
46
57
|
export interface ApiMainMenuItem {
|
|
47
58
|
id: string
|
|
@@ -65,6 +76,7 @@ export interface ApiSubMenu {
|
|
|
65
76
|
export interface ApiMenuData {
|
|
66
77
|
main: ApiMainMenuItem[]
|
|
67
78
|
subMenus: Record<string, ApiSubMenu>
|
|
79
|
+
userMenu?: Record<string, ApiSubMenu>
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
export interface MenuApiResponse {
|
|
@@ -59,28 +59,42 @@ export function MainSidebar({
|
|
|
59
59
|
onSecondarySidebarOpen()
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
const targetUrl = `${main_base_url}/${menuId}`
|
|
63
|
+
const path = targetUrl.startsWith('http://') || targetUrl.startsWith('https://')
|
|
64
|
+
? new URL(targetUrl).pathname
|
|
65
|
+
: targetUrl
|
|
66
|
+
window.location.href = path
|
|
67
|
+
|
|
68
68
|
if (!isMinimized) {
|
|
69
69
|
onToggle()
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const
|
|
73
|
+
const buildSubItemHref = (menuId: string, href: string): string => {
|
|
74
|
+
if (href.startsWith('http://') || href.startsWith('https://')) {
|
|
75
|
+
return new URL(href).pathname
|
|
76
|
+
}
|
|
77
|
+
const cleanHref = href.replace(/^\//, '')
|
|
78
|
+
const cleanBase = (main_base_url.startsWith('http://') || main_base_url.startsWith('https://'))
|
|
79
|
+
? new URL(main_base_url).pathname.replace(/\/$/, '')
|
|
80
|
+
: main_base_url.replace(/\/$/, '')
|
|
81
|
+
return `${cleanBase}/${menuId}/${cleanHref}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleSubMenuClick = (e: React.MouseEvent, menuId: string, href: string) => {
|
|
74
85
|
e.preventDefault()
|
|
75
|
-
|
|
76
|
-
? new URL(href).pathname
|
|
77
|
-
: href
|
|
78
|
-
window.location.href = path
|
|
86
|
+
window.location.href = buildSubItemHref(menuId, href)
|
|
79
87
|
setHoveredMenu(null)
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
return (
|
|
83
91
|
<>
|
|
92
|
+
{isOpen && !isMinimized && !hoveredMenu && (
|
|
93
|
+
<div
|
|
94
|
+
className="fixed inset-0 bg-black/40 z-40"
|
|
95
|
+
onClick={onToggle}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
84
98
|
|
|
85
99
|
<div className={cn(
|
|
86
100
|
"fixed left-0 top-0 h-full bg-white border-r border-ui-border z-50 transform transition-all duration-300 ease-in-out",
|
|
@@ -112,10 +126,11 @@ export function MainSidebar({
|
|
|
112
126
|
"overflow-y-auto h-full",
|
|
113
127
|
isMinimized ? "p-2" : "p-3"
|
|
114
128
|
)}>
|
|
115
|
-
<nav className="space-y-
|
|
129
|
+
<nav className="space-y-1">
|
|
116
130
|
{mainMenuItems.map((item) => {
|
|
117
131
|
const Icon = item.icon
|
|
118
132
|
const hasSubMenu = sidebarMenus[item.id]?.length > 0
|
|
133
|
+
const isActive = currentMenu === item.id
|
|
119
134
|
return (
|
|
120
135
|
<div
|
|
121
136
|
key={item.id}
|
|
@@ -126,21 +141,19 @@ export function MainSidebar({
|
|
|
126
141
|
<button
|
|
127
142
|
onClick={() => handleMenuClick(item.id)}
|
|
128
143
|
className={cn(
|
|
129
|
-
"w-full flex items-center text-left
|
|
130
|
-
isMinimized
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? "bg-interactive/10 text-interactive"
|
|
135
|
-
: "text-text-primary hover:bg-ui-background hover:text-text-primary"
|
|
144
|
+
"w-full flex items-center text-left transition-all duration-150 font-medium",
|
|
145
|
+
isMinimized ? "justify-center p-3" : "gap-3 px-3 py-2",
|
|
146
|
+
isActive
|
|
147
|
+
? "bg-interactive/10 text-interactive border-l-4 border-interactive"
|
|
148
|
+
: "text-text-primary hover:bg-ui-background border-l-4 border-transparent"
|
|
136
149
|
)}
|
|
137
150
|
title={isMinimized ? item.label : undefined}
|
|
138
151
|
>
|
|
139
152
|
<Icon className={cn(
|
|
140
|
-
"h-
|
|
141
|
-
|
|
153
|
+
"h-5 w-5 flex-shrink-0",
|
|
154
|
+
isActive ? "text-interactive" : "text-text-secondary"
|
|
142
155
|
)} />
|
|
143
|
-
{!isMinimized && <span className="text-
|
|
156
|
+
{!isMinimized && <span className="text-sm">{item.label}</span>}
|
|
144
157
|
</button>
|
|
145
158
|
</div>
|
|
146
159
|
)
|
|
@@ -154,24 +167,25 @@ export function MainSidebar({
|
|
|
154
167
|
if (!activeItem) return null
|
|
155
168
|
return (
|
|
156
169
|
<div
|
|
157
|
-
className="fixed bg-white border border-ui-border
|
|
170
|
+
className="fixed bg-white border border-ui-border shadow-lg z-[9999] min-w-[220px] py-1"
|
|
158
171
|
style={{ top: flyoutPos.top, left: flyoutPos.left }}
|
|
159
172
|
onMouseEnter={() => handleFlyoutMouseEnter(hoveredMenu)}
|
|
160
173
|
onMouseLeave={handleFlyoutMouseLeave}
|
|
161
174
|
>
|
|
162
|
-
|
|
175
|
+
{/* Arrow pointing left */}
|
|
176
|
+
<div className="absolute -left-[5px] top-[14px] w-[10px] h-[10px] bg-white border-l border-t border-ui-border -rotate-45" />
|
|
163
177
|
<div className="px-4 py-2 border-b border-ui-border">
|
|
164
|
-
<
|
|
178
|
+
<p className="text-xs font-semibold text-text-primary uppercase tracking-wide">{activeItem.label}</p>
|
|
165
179
|
</div>
|
|
166
|
-
<div
|
|
180
|
+
<div>
|
|
167
181
|
{sidebarMenus[hoveredMenu].map((subItem) => (
|
|
168
182
|
<a
|
|
169
183
|
key={subItem.id}
|
|
170
|
-
href={subItem.href}
|
|
171
|
-
onClick={(e) => handleSubMenuClick(e, subItem.href)}
|
|
172
|
-
className="flex items-center gap-3 px-4 py-2 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline"
|
|
184
|
+
href={buildSubItemHref(hoveredMenu, subItem.href)}
|
|
185
|
+
onClick={(e) => handleSubMenuClick(e, hoveredMenu, subItem.href)}
|
|
186
|
+
className="flex items-center gap-3 px-4 py-2 text-sm text-text-secondary hover:bg-ui-background hover:text-text-primary transition-colors no-underline border-l-4 border-transparent hover:border-interactive"
|
|
173
187
|
>
|
|
174
|
-
<subItem.icon className="h-4 w-4" />
|
|
188
|
+
<subItem.icon className="h-4 w-4 flex-shrink-0 text-text-secondary" />
|
|
175
189
|
{subItem.name}
|
|
176
190
|
</a>
|
|
177
191
|
))}
|
|
@@ -21,12 +21,11 @@ import {
|
|
|
21
21
|
import {
|
|
22
22
|
Settings,
|
|
23
23
|
Menu,
|
|
24
|
-
Building2,
|
|
25
24
|
User,
|
|
26
25
|
ChevronUp,
|
|
27
26
|
} from "lucide-react"
|
|
28
27
|
import { Logo } from "../../ui/logo"
|
|
29
|
-
import { type SidebarMenus, type SubMenuItem } from "./data"
|
|
28
|
+
import { type SidebarMenus, type SubMenuItem, type OrgInfo } from "./data"
|
|
30
29
|
import { Skeleton } from "../skeleton"
|
|
31
30
|
|
|
32
31
|
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
|
@@ -173,16 +172,29 @@ interface SidebarProps {
|
|
|
173
172
|
main_base_url?: string
|
|
174
173
|
sectionLabels?: Record<string, string>
|
|
175
174
|
getCurrentMenuItem?: (pathname: string, searchParams: URLSearchParams) => string
|
|
175
|
+
loadOrg?: () => Promise<OrgInfo>
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
|
|
180
|
-
function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem }: SidebarProps = {}) {
|
|
180
|
+
function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_url = "", sectionLabels = {}, getCurrentMenuItem, loadOrg }: SidebarProps = {}) {
|
|
181
181
|
const pathname = usePathname()
|
|
182
182
|
const searchParams = useSearchParams()
|
|
183
183
|
const { state } = useSidebar()
|
|
184
184
|
const [settingsOpen, setSettingsOpen] = React.useState(false)
|
|
185
185
|
const settingsRef = React.useRef<HTMLDivElement>(null)
|
|
186
|
+
const [orgData, setOrgData] = React.useState<OrgInfo | null>(null)
|
|
187
|
+
const [orgLoading, setOrgLoading] = React.useState(false)
|
|
188
|
+
|
|
189
|
+
// Load org lazily when settings panel opens
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
if (settingsOpen && loadOrg && !orgData && !orgLoading) {
|
|
192
|
+
setOrgLoading(true)
|
|
193
|
+
loadOrg()
|
|
194
|
+
.then((data) => { setOrgData(data); setOrgLoading(false) })
|
|
195
|
+
.catch(() => setOrgLoading(false))
|
|
196
|
+
}
|
|
197
|
+
}, [settingsOpen, loadOrg, orgData, orgLoading])
|
|
186
198
|
|
|
187
199
|
// Close dropdown on click outside
|
|
188
200
|
React.useEffect(() => {
|
|
@@ -204,7 +216,7 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
204
216
|
|| []
|
|
205
217
|
|
|
206
218
|
return (
|
|
207
|
-
<div className="h-screen sticky top-0 flex flex-col bg-gray-50 border-r border-ui-border min-w-[var(--sidebar-width-icon)] transition-[width] duration-200"
|
|
219
|
+
<div className="h-screen sticky top-0 flex flex-col bg-gray-50 border-r border-ui-border min-w-[var(--sidebar-width-icon)] transition-[width] duration-200 overflow-visible"
|
|
208
220
|
style={{ width: state === "expanded" ? "var(--sidebar-width)" : "var(--sidebar-width-icon)" }}>
|
|
209
221
|
{/* Logo avec bouton de menu principal */}
|
|
210
222
|
<div className="h-14 flex items-center justify-between px-4 border-b border-ui-border">
|
|
@@ -252,13 +264,13 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
252
264
|
href={item.href}
|
|
253
265
|
onClick={handleClick}
|
|
254
266
|
className={cn(
|
|
255
|
-
"flex items-center px-3 py-2 text-sm
|
|
267
|
+
"flex items-center px-3 py-2 text-sm gap-x-3 transition-colors border-l-4",
|
|
256
268
|
isActive
|
|
257
|
-
? "bg-interactive/10 text-interactive font-medium"
|
|
258
|
-
: "text-text-secondary hover:bg-ui-background hover:text-text-primary"
|
|
269
|
+
? "bg-interactive/10 text-interactive font-medium border-interactive"
|
|
270
|
+
: "text-text-secondary hover:bg-ui-background hover:text-text-primary border-transparent"
|
|
259
271
|
)}
|
|
260
272
|
>
|
|
261
|
-
<item.icon className="h-4 w-4" />
|
|
273
|
+
<item.icon className={cn("h-4 w-4 flex-shrink-0", isActive ? "text-interactive" : "text-text-secondary")} />
|
|
262
274
|
{item.name}
|
|
263
275
|
</Link>
|
|
264
276
|
)
|
|
@@ -269,25 +281,42 @@ function Sidebar({ currentMenu, onMainMenuToggle, sidebarMenus = {}, main_base_u
|
|
|
269
281
|
<div className="relative border-t border-ui-border px-3 py-3" ref={settingsRef}>
|
|
270
282
|
{/* Dropdown vers le haut */}
|
|
271
283
|
{settingsOpen && (
|
|
272
|
-
<div className="absolute bottom-full left-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
className="
|
|
286
|
-
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
284
|
+
<div className="absolute bottom-full left-0 mb-3 bg-white border border-ui-border shadow-2xl z-50 min-w-[380px]">
|
|
285
|
+
{/* Flèche pointant vers le bas en direction du bouton Paramètres */}
|
|
286
|
+
<div className="absolute -bottom-[5px] left-6 w-[10px] h-[10px] bg-white border-r border-b border-ui-border rotate-45" />
|
|
287
|
+
|
|
288
|
+
{/* Organisation */}
|
|
289
|
+
<Link
|
|
290
|
+
href={`${main_base_url}/organization`}
|
|
291
|
+
onClick={() => setSettingsOpen(false)}
|
|
292
|
+
className="flex items-center gap-4 px-5 py-4 hover:bg-ui-background transition-colors border-b border-ui-border no-underline"
|
|
293
|
+
>
|
|
294
|
+
{orgLoading ? (
|
|
295
|
+
<div className="h-14 w-14 bg-ui-background animate-pulse flex-shrink-0" />
|
|
296
|
+
) : orgData?.logo ? (
|
|
297
|
+
<img src={orgData.logo} alt={orgData.nom} className="h-14 w-14 object-cover flex-shrink-0" />
|
|
298
|
+
) : (
|
|
299
|
+
<div className="h-14 w-14 bg-interactive flex items-center justify-center text-white text-2xl font-bold flex-shrink-0">
|
|
300
|
+
{orgData?.nom?.charAt(0)?.toUpperCase() ?? 'O'}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
<div className="flex flex-col min-w-0">
|
|
304
|
+
<span className="text-xs text-text-secondary uppercase tracking-wide mb-1">Organisation</span>
|
|
305
|
+
<span className="text-base font-semibold text-text-primary truncate">
|
|
306
|
+
{orgLoading ? '…' : (orgData?.nom ?? 'Organisation')}
|
|
307
|
+
</span>
|
|
308
|
+
</div>
|
|
309
|
+
</Link>
|
|
310
|
+
|
|
311
|
+
{/* Paramètres du compte */}
|
|
312
|
+
<Link
|
|
313
|
+
href={`${main_base_url}/profile`}
|
|
314
|
+
onClick={() => setSettingsOpen(false)}
|
|
315
|
+
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"
|
|
316
|
+
>
|
|
317
|
+
<User className="h-5 w-5 flex-shrink-0" />
|
|
318
|
+
<span>Paramètres du compte</span>
|
|
319
|
+
</Link>
|
|
291
320
|
</div>
|
|
292
321
|
)}
|
|
293
322
|
<button
|