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