@creezio/shell-ui 0.22.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.
Files changed (232) hide show
  1. package/README.md +336 -0
  2. package/dist/adapters/nav-shell.d.ts +54 -0
  3. package/dist/adapters/nav-shell.d.ts.map +1 -0
  4. package/dist/adapters/nav-shell.js +112 -0
  5. package/dist/adapters/nav-shell.js.map +1 -0
  6. package/dist/brand.d.ts +64 -0
  7. package/dist/brand.d.ts.map +1 -0
  8. package/dist/brand.js +59 -0
  9. package/dist/brand.js.map +1 -0
  10. package/dist/core-nav.d.ts +17 -0
  11. package/dist/core-nav.d.ts.map +1 -0
  12. package/dist/core-nav.js +40 -0
  13. package/dist/core-nav.js.map +1 -0
  14. package/dist/index.d.ts +29 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/api-scopes.d.ts +24 -0
  19. package/dist/lib/api-scopes.d.ts.map +1 -0
  20. package/dist/lib/api-scopes.js +78 -0
  21. package/dist/lib/api-scopes.js.map +1 -0
  22. package/dist/lib/catalog-suspense-key.d.ts +3 -0
  23. package/dist/lib/catalog-suspense-key.d.ts.map +1 -0
  24. package/dist/lib/catalog-suspense-key.js +12 -0
  25. package/dist/lib/catalog-suspense-key.js.map +1 -0
  26. package/dist/lib/data-changed.d.ts +51 -0
  27. package/dist/lib/data-changed.d.ts.map +1 -0
  28. package/dist/lib/data-changed.js +227 -0
  29. package/dist/lib/data-changed.js.map +1 -0
  30. package/dist/lib/desktop-home-path.d.ts +14 -0
  31. package/dist/lib/desktop-home-path.d.ts.map +1 -0
  32. package/dist/lib/desktop-home-path.js +29 -0
  33. package/dist/lib/desktop-home-path.js.map +1 -0
  34. package/dist/lib/geo-distance.d.ts +6 -0
  35. package/dist/lib/geo-distance.d.ts.map +1 -0
  36. package/dist/lib/geo-distance.js +27 -0
  37. package/dist/lib/geo-distance.js.map +1 -0
  38. package/dist/lib/img.d.ts +2 -0
  39. package/dist/lib/img.d.ts.map +1 -0
  40. package/dist/lib/img.js +40 -0
  41. package/dist/lib/img.js.map +1 -0
  42. package/dist/lib/keepalive-eviction.d.ts +14 -0
  43. package/dist/lib/keepalive-eviction.d.ts.map +1 -0
  44. package/dist/lib/keepalive-eviction.js +44 -0
  45. package/dist/lib/keepalive-eviction.js.map +1 -0
  46. package/dist/lib/ops-track.d.ts +29 -0
  47. package/dist/lib/ops-track.d.ts.map +1 -0
  48. package/dist/lib/ops-track.js +37 -0
  49. package/dist/lib/ops-track.js.map +1 -0
  50. package/dist/lib/optimize-cover-url.d.ts +10 -0
  51. package/dist/lib/optimize-cover-url.d.ts.map +1 -0
  52. package/dist/lib/optimize-cover-url.js +80 -0
  53. package/dist/lib/optimize-cover-url.js.map +1 -0
  54. package/dist/lib/page-trails.d.ts +9 -0
  55. package/dist/lib/page-trails.d.ts.map +1 -0
  56. package/dist/lib/page-trails.js +20 -0
  57. package/dist/lib/page-trails.js.map +1 -0
  58. package/dist/lib/public-origin.d.ts +32 -0
  59. package/dist/lib/public-origin.d.ts.map +1 -0
  60. package/dist/lib/public-origin.js +125 -0
  61. package/dist/lib/public-origin.js.map +1 -0
  62. package/dist/lib/server-incident.d.ts +12 -0
  63. package/dist/lib/server-incident.d.ts.map +1 -0
  64. package/dist/lib/server-incident.js +72 -0
  65. package/dist/lib/server-incident.js.map +1 -0
  66. package/dist/lib/tab-document-url.d.ts +13 -0
  67. package/dist/lib/tab-document-url.d.ts.map +1 -0
  68. package/dist/lib/tab-document-url.js +60 -0
  69. package/dist/lib/tab-document-url.js.map +1 -0
  70. package/dist/lib/utils.d.ts +20 -0
  71. package/dist/lib/utils.d.ts.map +1 -0
  72. package/dist/lib/utils.js +87 -0
  73. package/dist/lib/utils.js.map +1 -0
  74. package/dist/nav-catalog.d.ts +97 -0
  75. package/dist/nav-catalog.d.ts.map +1 -0
  76. package/dist/nav-catalog.js +282 -0
  77. package/dist/nav-catalog.js.map +1 -0
  78. package/dist/registry.d.ts +11 -0
  79. package/dist/registry.d.ts.map +1 -0
  80. package/dist/registry.js +60 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/types.d.ts +22 -0
  83. package/dist/types.d.ts.map +1 -0
  84. package/dist/types.js +2 -0
  85. package/dist/types.js.map +1 -0
  86. package/dist-cjs/adapters/nav-shell.js +115 -0
  87. package/dist-cjs/adapters/nav-shell.js.map +1 -0
  88. package/dist-cjs/brand.js +66 -0
  89. package/dist-cjs/brand.js.map +1 -0
  90. package/dist-cjs/core-nav.js +43 -0
  91. package/dist-cjs/core-nav.js.map +1 -0
  92. package/dist-cjs/index.js +95 -0
  93. package/dist-cjs/index.js.map +1 -0
  94. package/dist-cjs/lib/api-scopes.js +86 -0
  95. package/dist-cjs/lib/api-scopes.js.map +1 -0
  96. package/dist-cjs/lib/catalog-suspense-key.js +15 -0
  97. package/dist-cjs/lib/catalog-suspense-key.js.map +1 -0
  98. package/dist-cjs/lib/data-changed.js +236 -0
  99. package/dist-cjs/lib/data-changed.js.map +1 -0
  100. package/dist-cjs/lib/desktop-home-path.js +34 -0
  101. package/dist-cjs/lib/desktop-home-path.js.map +1 -0
  102. package/dist-cjs/lib/geo-distance.js +30 -0
  103. package/dist-cjs/lib/geo-distance.js.map +1 -0
  104. package/dist-cjs/lib/img.js +43 -0
  105. package/dist-cjs/lib/img.js.map +1 -0
  106. package/dist-cjs/lib/keepalive-eviction.js +49 -0
  107. package/dist-cjs/lib/keepalive-eviction.js.map +1 -0
  108. package/dist-cjs/lib/ops-track.js +41 -0
  109. package/dist-cjs/lib/ops-track.js.map +1 -0
  110. package/dist-cjs/lib/optimize-cover-url.js +83 -0
  111. package/dist-cjs/lib/optimize-cover-url.js.map +1 -0
  112. package/dist-cjs/lib/page-trails.js +25 -0
  113. package/dist-cjs/lib/page-trails.js.map +1 -0
  114. package/dist-cjs/lib/public-origin.js +130 -0
  115. package/dist-cjs/lib/public-origin.js.map +1 -0
  116. package/dist-cjs/lib/server-incident.js +75 -0
  117. package/dist-cjs/lib/server-incident.js.map +1 -0
  118. package/dist-cjs/lib/tab-document-url.js +65 -0
  119. package/dist-cjs/lib/tab-document-url.js.map +1 -0
  120. package/dist-cjs/lib/utils.js +97 -0
  121. package/dist-cjs/lib/utils.js.map +1 -0
  122. package/dist-cjs/nav-catalog.js +293 -0
  123. package/dist-cjs/nav-catalog.js.map +1 -0
  124. package/dist-cjs/package.json +3 -0
  125. package/dist-cjs/registry.js +64 -0
  126. package/dist-cjs/registry.js.map +1 -0
  127. package/dist-cjs/types.js +3 -0
  128. package/dist-cjs/types.js.map +1 -0
  129. package/package.json +163 -0
  130. package/ui/app-error-boundary.tsx +59 -0
  131. package/ui/data-table.tsx +121 -0
  132. package/ui/desktop/auth-window-chrome.tsx +51 -0
  133. package/ui/desktop/desktop-bridge.tsx +87 -0
  134. package/ui/desktop/external-site-slot.tsx +313 -0
  135. package/ui/desktop/external-site-surface.ts +58 -0
  136. package/ui/desktop/site-link.tsx +105 -0
  137. package/ui/desktop/window-chrome-controls.tsx +5 -0
  138. package/ui/desktop-types.ts +30 -0
  139. package/ui/faceted-filters.tsx +119 -0
  140. package/ui/global-search-host.ts +20 -0
  141. package/ui/global-search.tsx +66 -0
  142. package/ui/index.ts +178 -0
  143. package/ui/kit.ts +30 -0
  144. package/ui/layout/app-shell.tsx +58 -0
  145. package/ui/layout/desktop-update-banner.tsx +100 -0
  146. package/ui/layout/entity-header.tsx +44 -0
  147. package/ui/layout/native-os-nav.ts +104 -0
  148. package/ui/layout/nav-catalog-loader.tsx +98 -0
  149. package/ui/layout/nav-icons.ts +67 -0
  150. package/ui/layout/page-chrome.tsx +90 -0
  151. package/ui/layout/page-toolbar-context.tsx +82 -0
  152. package/ui/layout/sandbox-banner.tsx +38 -0
  153. package/ui/layout/section-view-shell.tsx +73 -0
  154. package/ui/layout/sidebar-host.ts +144 -0
  155. package/ui/layout/sidebar.tsx +1150 -0
  156. package/ui/lib/ai-screencast-hub.ts +127 -0
  157. package/ui/lib/ai-workspace-client.ts +46 -0
  158. package/ui/lib/aid.ts +22 -0
  159. package/ui/lib/desktop-host.ts +114 -0
  160. package/ui/lib/fleet-tracker-client.ts +237 -0
  161. package/ui/lib/hermes-ui.ts +114 -0
  162. package/ui/lib/n8n-ui.ts +115 -0
  163. package/ui/lib/use-creezio-resource.tsx +121 -0
  164. package/ui/lib/use-shell-ui-brand.ts +22 -0
  165. package/ui/list-toolbar.tsx +309 -0
  166. package/ui/os-pages/desktop-settings-page.tsx +158 -0
  167. package/ui/os-pages/index.ts +8 -0
  168. package/ui/page-loading/entity-page-loading.tsx +59 -0
  169. package/ui/page-loading/list-page-loading.tsx +61 -0
  170. package/ui/pagination.tsx +56 -0
  171. package/ui/primitives/avatar.tsx +33 -0
  172. package/ui/primitives/badge.tsx +36 -0
  173. package/ui/primitives/breadcrumb.tsx +93 -0
  174. package/ui/primitives/button.tsx +44 -0
  175. package/ui/primitives/card.tsx +44 -0
  176. package/ui/primitives/chart.tsx +317 -0
  177. package/ui/primitives/command.tsx +111 -0
  178. package/ui/primitives/dialog.tsx +100 -0
  179. package/ui/primitives/dropdown-menu.tsx +187 -0
  180. package/ui/primitives/input.tsx +19 -0
  181. package/ui/primitives/label.tsx +17 -0
  182. package/ui/primitives/resizable.tsx +45 -0
  183. package/ui/primitives/scroll-area.tsx +41 -0
  184. package/ui/primitives/select.tsx +73 -0
  185. package/ui/primitives/separator.tsx +26 -0
  186. package/ui/primitives/sheet.tsx +90 -0
  187. package/ui/primitives/skeleton.tsx +7 -0
  188. package/ui/primitives/sonner.tsx +22 -0
  189. package/ui/primitives/tabs.tsx +51 -0
  190. package/ui/primitives/textarea.tsx +22 -0
  191. package/ui/primitives/tooltip.tsx +31 -0
  192. package/ui/pwa/client-error-reporter.tsx +62 -0
  193. package/ui/pwa/register-sw.tsx +50 -0
  194. package/ui/range-filters.tsx +174 -0
  195. package/ui/search/global-search-config.ts +32 -0
  196. package/ui/search/global-search-provider.tsx +528 -0
  197. package/ui/search/search-history.ts +74 -0
  198. package/ui/search-input.tsx +146 -0
  199. package/ui/settings/account-settings.tsx +140 -0
  200. package/ui/settings/agent-profile-settings.tsx +184 -0
  201. package/ui/settings/api-keys-settings.tsx +349 -0
  202. package/ui/settings/desktop-background-settings.tsx +128 -0
  203. package/ui/settings/desktop-connection-settings.tsx +251 -0
  204. package/ui/settings/desktop-embed-env-panel.tsx +257 -0
  205. package/ui/settings/desktop-fleet-telemetry-settings.tsx +269 -0
  206. package/ui/settings/desktop-hermes-settings.tsx +341 -0
  207. package/ui/settings/desktop-llm-keys.tsx +192 -0
  208. package/ui/settings/desktop-n8n-settings.tsx +306 -0
  209. package/ui/settings/desktop-tunnel.tsx +264 -0
  210. package/ui/settings/desktop-update-settings.tsx +175 -0
  211. package/ui/settings/factory-reset-settings.tsx +89 -0
  212. package/ui/settings/host-only-settings.tsx +62 -0
  213. package/ui/settings/locked-config-field.tsx +30 -0
  214. package/ui/settings/ops-diagnostic-settings.tsx +242 -0
  215. package/ui/settings/search-reindex-settings.tsx +158 -0
  216. package/ui/settings/server-mode-cards.tsx +801 -0
  217. package/ui/theme/tailwind-preset.cjs +82 -0
  218. package/ui/theme/theme.css +328 -0
  219. package/ui/workspace/ai-activity-panel-host.tsx +19 -0
  220. package/ui/workspace/ai-workspace-agent-host.tsx +54 -0
  221. package/ui/workspace/ai-workspace-banner.tsx +168 -0
  222. package/ui/workspace/impersonation-banner.tsx +39 -0
  223. package/ui/workspace/keep-alive.tsx +220 -0
  224. package/ui/workspace/tab-workspace-context.tsx +1172 -0
  225. package/ui/workspace/tab-workspace-host.ts +89 -0
  226. package/ui/workspace/types.ts +500 -0
  227. package/ui/workspace/use-location-search.tsx +50 -0
  228. package/ui/workspace/window-chrome-controls.tsx +116 -0
  229. package/ui/workspace/workspace-config.ts +47 -0
  230. package/ui/workspace/workspace-root.tsx +147 -0
  231. package/ui/workspace/workspace-shell.tsx +292 -0
  232. package/ui/workspace/workspace-tab-bar.tsx +572 -0
@@ -0,0 +1,1150 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ useSyncExternalStore,
10
+ type RefObject,
11
+ } from "react";
12
+ import Link from "next/link";
13
+ import { usePathname, useRouter } from "next/navigation";
14
+ import {
15
+ Bot,
16
+ ChevronDown,
17
+ Eye,
18
+ LogOut,
19
+ PanelLeftClose,
20
+ PanelLeftOpen,
21
+ Puzzle,
22
+ Shield,
23
+ Workflow,
24
+ X,
25
+ } from "lucide-react";
26
+ import { toast } from "sonner";
27
+ import { useSession } from "@creezio/auth/ui";
28
+ import { cn, getShellDesktopApi, getShellUiBrand } from "@creezio/shell-ui";
29
+ import { Button } from "../primitives/button";
30
+ import { Avatar, AvatarFallback } from "../primitives/avatar";
31
+ import { useLocationSearch } from "../workspace/use-location-search";
32
+ import { useTabWorkspaceOptional } from "../workspace/tab-workspace-context";
33
+ import { openAiWorkspaceView } from "../lib/ai-workspace-client";
34
+ import {
35
+ isHermesWebuiOpenTarget,
36
+ resolveHermesWebuiOpenTarget,
37
+ } from "../lib/hermes-ui";
38
+ import { openN8nUiInWorkspace } from "../lib/n8n-ui";
39
+ import {
40
+ getSidebarActionsSnapshot,
41
+ getSidebarHost,
42
+ getSidebarHostVersion,
43
+ subscribeSidebarActions,
44
+ subscribeSidebarHost,
45
+ type SidebarActionItem,
46
+ type SidebarAdminItem,
47
+ type SidebarHost,
48
+ type SidebarNavItem,
49
+ } from "./sidebar-host";
50
+
51
+ function aidProps(value: string): Record<string, string> {
52
+ const attr = getShellUiBrand().aidAttr ?? "data-creezio-aid";
53
+ return { [attr]: value };
54
+ }
55
+
56
+ function aidSlug(label: string): string {
57
+ return label
58
+ .toLowerCase()
59
+ .normalize("NFD")
60
+ .replace(/[\u0300-\u036f]/g, "")
61
+ .replace(/[^a-z0-9]+/g, "-")
62
+ .replace(/^-+|-+$/g, "");
63
+ }
64
+
65
+ function hrefPath(href: string): string {
66
+ return href.split("?")[0] || href;
67
+ }
68
+
69
+ function canShowHref(host: SidebarHost, href: string, me: any): boolean {
70
+ return host.canShowHref?.(href, me) !== false;
71
+ }
72
+
73
+ function hasItemPermission(
74
+ item: { permission?: string | null },
75
+ me: any,
76
+ ): boolean {
77
+ if (!item.permission) return true;
78
+ if (!me) return true;
79
+ // Owner non impersonné : tout voir — même bypass que la garde API kernel
80
+ // (authorizeModuleAccess). Les ownerPermissions configurées restent la
81
+ // source pour le reste (impersonation = permissions de la cible).
82
+ if (me.role === "owner" && me.impersonating !== true) return true;
83
+ const permissions = Array.isArray(me.permissions) ? me.permissions : [];
84
+ return permissions.includes(item.permission);
85
+ }
86
+
87
+ function isActiveHref(pathname: string, href: string): boolean {
88
+ const path = hrefPath(href);
89
+ if (path === "/") return pathname === "/";
90
+ return pathname === path || pathname.startsWith(path + "/");
91
+ }
92
+
93
+ function DefaultBrandMark({ className }: { className?: string }) {
94
+ const productName = getShellUiBrand().productName || "Creezio";
95
+ const initials =
96
+ productName
97
+ .split(/\s+/)
98
+ .filter(Boolean)
99
+ .slice(0, 2)
100
+ .map((part) => part[0])
101
+ .join("")
102
+ .toUpperCase() || "C";
103
+
104
+ return (
105
+ <div
106
+ className={cn(
107
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
108
+ "bg-gradient-to-br from-slate-500 to-slate-700 text-[11px] font-bold tracking-tight text-white shadow-sm",
109
+ className,
110
+ )}
111
+ >
112
+ {initials}
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function BrandMark({
118
+ host,
119
+ className,
120
+ }: {
121
+ host: SidebarHost;
122
+ className?: string;
123
+ }) {
124
+ if (host.renderBrandMark) return host.renderBrandMark({ className });
125
+ return <DefaultBrandMark className={className} />;
126
+ }
127
+
128
+ function DefaultToolsLinks({
129
+ host,
130
+ me,
131
+ collapsed,
132
+ nested,
133
+ onNavigate,
134
+ }: {
135
+ host: SidebarHost;
136
+ me: any;
137
+ collapsed?: boolean;
138
+ nested?: boolean;
139
+ onNavigate?: () => void;
140
+ }) {
141
+ return (
142
+ <>
143
+ <HermesSidebarLink
144
+ host={host}
145
+ me={me}
146
+ collapsed={collapsed}
147
+ nested={nested}
148
+ onNavigate={onNavigate}
149
+ />
150
+ <N8nSidebarLink
151
+ host={host}
152
+ me={me}
153
+ collapsed={collapsed}
154
+ nested={nested}
155
+ onNavigate={onNavigate}
156
+ />
157
+ </>
158
+ );
159
+ }
160
+
161
+ function AdminNavGroup({
162
+ host,
163
+ me,
164
+ pathname,
165
+ collapsed,
166
+ onNavigate,
167
+ }: {
168
+ host: SidebarHost;
169
+ me: any;
170
+ pathname: string;
171
+ collapsed?: boolean;
172
+ onNavigate?: () => void;
173
+ }) {
174
+ const adminItems = useMemo(
175
+ () =>
176
+ (host.getAdminItems?.() || []).filter(
177
+ (item) =>
178
+ hasItemPermission(item, me) && canShowHref(host, hrefPath(item.href), me),
179
+ ),
180
+ [host, me],
181
+ );
182
+ const adminActive =
183
+ pathname === "/admin" ||
184
+ pathname.startsWith("/admin/") ||
185
+ pathname === "/configuration" ||
186
+ pathname.startsWith("/configuration/");
187
+ const [open, setOpen] = useState(adminActive);
188
+ const [popover, setPopover] = useState(false);
189
+ const popRef = useRef<HTMLDivElement>(null);
190
+
191
+ useEffect(() => {
192
+ if (adminActive) setOpen(true);
193
+ }, [adminActive]);
194
+
195
+ useEffect(() => {
196
+ if (!popover) return;
197
+ function onDoc(e: MouseEvent) {
198
+ if (!popRef.current?.contains(e.target as Node)) setPopover(false);
199
+ }
200
+ document.addEventListener("mousedown", onDoc);
201
+ return () => document.removeEventListener("mousedown", onDoc);
202
+ }, [popover]);
203
+
204
+ if (!adminItems.length && !host.renderTools && !canShowHref(host, "/admin", me)) {
205
+ return null;
206
+ }
207
+
208
+ const toolLinks = host.renderTools ? (
209
+ host.renderTools({ collapsed: false, me })
210
+ ) : (
211
+ <DefaultToolsLinks
212
+ host={host}
213
+ me={me}
214
+ collapsed={false}
215
+ nested
216
+ onNavigate={() => {
217
+ setPopover(false);
218
+ onNavigate?.();
219
+ }}
220
+ />
221
+ );
222
+
223
+ if (collapsed) {
224
+ return (
225
+ <div className="relative" ref={popRef}>
226
+ <button
227
+ type="button"
228
+ title="Admin"
229
+ aria-label="Admin"
230
+ aria-expanded={popover}
231
+ onClick={() => setPopover((v) => !v)}
232
+ {...aidProps("nav.admin")}
233
+ className={cn(
234
+ "flex h-9 w-full items-center justify-center rounded-lg text-sm transition-colors",
235
+ adminActive
236
+ ? "bg-slate-800 text-white"
237
+ : "text-slate-300 hover:bg-slate-800/60 hover:text-white",
238
+ )}
239
+ >
240
+ <Shield className="h-4 w-4 shrink-0" />
241
+ </button>
242
+ {popover ? (
243
+ <div className="absolute left-full top-0 z-50 ml-2 min-w-[12rem] rounded-lg border border-slate-700 bg-slate-900 py-1 shadow-xl">
244
+ {adminItems.map((item) => {
245
+ const active = isActiveHref(pathname, item.href);
246
+ const Icon = item.icon;
247
+ return (
248
+ <Link
249
+ key={item.href}
250
+ href={item.href}
251
+ onClick={() => {
252
+ setPopover(false);
253
+ onNavigate?.();
254
+ }}
255
+ className={cn(
256
+ "flex items-center gap-2 px-3 py-2 text-sm",
257
+ active
258
+ ? "bg-slate-800 text-white"
259
+ : "text-slate-300 hover:bg-slate-800/60 hover:text-white",
260
+ )}
261
+ >
262
+ <Icon className="h-3.5 w-3.5" />
263
+ {item.label}
264
+ </Link>
265
+ );
266
+ })}
267
+ {toolLinks ? (
268
+ <>
269
+ {adminItems.length ? (
270
+ <div className="my-1 border-t border-slate-700" />
271
+ ) : null}
272
+ <div className="px-1">{toolLinks}</div>
273
+ </>
274
+ ) : null}
275
+ </div>
276
+ ) : null}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ return (
282
+ <div className="space-y-0.5">
283
+ <button
284
+ type="button"
285
+ onClick={() => setOpen((v) => !v)}
286
+ {...aidProps("nav.admin")}
287
+ className={cn(
288
+ "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
289
+ adminActive
290
+ ? "bg-slate-800/80 text-white"
291
+ : "text-slate-300 hover:bg-slate-800/60 hover:text-white",
292
+ )}
293
+ >
294
+ <Shield className="h-4 w-4 shrink-0" />
295
+ <span className="flex-1 text-left">Admin</span>
296
+ <ChevronDown
297
+ className={cn(
298
+ "h-3.5 w-3.5 shrink-0 transition-transform",
299
+ open ? "rotate-0" : "-rotate-90",
300
+ )}
301
+ />
302
+ </button>
303
+ {open ? (
304
+ <div className="ml-3 space-y-0.5 border-l border-slate-700 pl-2">
305
+ {adminItems.map((item) => {
306
+ const active = isActiveHref(pathname, item.href);
307
+ const Icon = item.icon;
308
+ return (
309
+ <Link
310
+ key={item.href}
311
+ href={item.href}
312
+ onClick={onNavigate}
313
+ {...aidProps(`nav.${aidSlug(item.label)}`)}
314
+ className={cn(
315
+ "flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors",
316
+ active
317
+ ? "bg-slate-700 text-white"
318
+ : "text-slate-400 hover:bg-slate-800/60 hover:text-white",
319
+ )}
320
+ >
321
+ <Icon className="h-3.5 w-3.5 shrink-0" />
322
+ {item.label}
323
+ </Link>
324
+ );
325
+ })}
326
+ {toolLinks ? (
327
+ <>
328
+ <div className="px-2.5 pb-0.5 pt-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">
329
+ Outils
330
+ </div>
331
+ {toolLinks}
332
+ </>
333
+ ) : null}
334
+ </div>
335
+ ) : null}
336
+ </div>
337
+ );
338
+ }
339
+
340
+ function HermesSidebarLink({
341
+ host,
342
+ me,
343
+ collapsed,
344
+ nested,
345
+ onNavigate,
346
+ }: {
347
+ host: SidebarHost;
348
+ me: any;
349
+ collapsed?: boolean;
350
+ nested?: boolean;
351
+ onNavigate?: () => void;
352
+ }) {
353
+ const workspace = useTabWorkspaceOptional();
354
+ const router = useRouter();
355
+ const [visible, setVisible] = useState(false);
356
+
357
+ useEffect(() => {
358
+ if (!canShowHref(host, "/admin/hermes", me)) return;
359
+ const api = getShellDesktopApi();
360
+ if (!api?.getHermesStatus) return;
361
+ let cancelled = false;
362
+ const refresh = () => {
363
+ void api.getHermesStatus?.().then(() => {
364
+ if (!cancelled) setVisible(true);
365
+ });
366
+ };
367
+ refresh();
368
+ const t = window.setInterval(refresh, 8000);
369
+ return () => {
370
+ cancelled = true;
371
+ window.clearInterval(t);
372
+ };
373
+ }, [host, me]);
374
+
375
+ if (!visible) return null;
376
+
377
+ async function openHermes() {
378
+ onNavigate?.();
379
+ let retryToast: string | number | undefined;
380
+ const target = await resolveHermesWebuiOpenTarget({
381
+ onRetry: () => {
382
+ retryToast = toast.loading(
383
+ "Hermes est arrêté - relance de l'API et de la WebUI...",
384
+ );
385
+ },
386
+ });
387
+ if (retryToast !== undefined) toast.dismiss(retryToast);
388
+ if (!isHermesWebuiOpenTarget(target)) {
389
+ toast.error(target.error, { duration: 9000 });
390
+ if (/Configuration|desactive|désactivé|URL/i.test(target.error)) {
391
+ router.push("/configuration");
392
+ }
393
+ return;
394
+ }
395
+ if (workspace?.openExternalSite) {
396
+ workspace.openExternalSite({
397
+ siteId: target.siteId,
398
+ url: target.url,
399
+ title: target.title,
400
+ });
401
+ return;
402
+ }
403
+ const api = getShellDesktopApi();
404
+ if (api?.openTab) {
405
+ try {
406
+ await api.openTab(target.siteId, target.url);
407
+ return;
408
+ } catch {
409
+ /* fall through */
410
+ }
411
+ }
412
+ window.open(target.url, "_blank", "noreferrer");
413
+ }
414
+
415
+ return (
416
+ <button
417
+ type="button"
418
+ onClick={() => void openHermes()}
419
+ {...aidProps("nav.hermes")}
420
+ title={collapsed ? "Agent Hermes" : undefined}
421
+ className={cn(
422
+ "flex w-full items-center rounded-lg text-sm transition-colors",
423
+ nested
424
+ ? "gap-2.5 px-2.5 py-1.5 text-slate-400 hover:bg-slate-800/60 hover:text-white"
425
+ : collapsed
426
+ ? "h-9 justify-center text-slate-300 hover:bg-slate-800/60 hover:text-white"
427
+ : "gap-3 px-3 py-2 text-slate-300 hover:bg-slate-800/60 hover:text-white",
428
+ )}
429
+ >
430
+ <Bot className={cn("shrink-0", nested ? "h-3.5 w-3.5" : "h-4 w-4")} />
431
+ {collapsed && !nested ? (
432
+ <span className="sr-only">Agent Hermes</span>
433
+ ) : (
434
+ "Agent Hermes"
435
+ )}
436
+ </button>
437
+ );
438
+ }
439
+
440
+ function N8nSidebarLink({
441
+ host,
442
+ me,
443
+ collapsed,
444
+ nested,
445
+ onNavigate,
446
+ }: {
447
+ host: SidebarHost;
448
+ me: any;
449
+ collapsed?: boolean;
450
+ nested?: boolean;
451
+ onNavigate?: () => void;
452
+ }) {
453
+ const workspace = useTabWorkspaceOptional();
454
+ const router = useRouter();
455
+ const [visible, setVisible] = useState(false);
456
+
457
+ useEffect(() => {
458
+ if (!canShowHref(host, "/admin/n8n", me)) return;
459
+ const api = getShellDesktopApi();
460
+ if (!api?.getN8nStatus) return;
461
+ let cancelled = false;
462
+ const refresh = () => {
463
+ void api.getN8nStatus?.().then(() => {
464
+ if (!cancelled) setVisible(true);
465
+ });
466
+ };
467
+ refresh();
468
+ const t = window.setInterval(refresh, 8000);
469
+ return () => {
470
+ cancelled = true;
471
+ window.clearInterval(t);
472
+ };
473
+ }, [host, me]);
474
+
475
+ if (!visible) return null;
476
+
477
+ async function openN8n() {
478
+ onNavigate?.();
479
+ const api = getShellDesktopApi();
480
+ const r = await openN8nUiInWorkspace({
481
+ openExternalSite: workspace?.openExternalSite
482
+ ? (o) => workspace.openExternalSite(o)
483
+ : undefined,
484
+ openTab: api?.openTab ? (siteId, url) => api.openTab(siteId, url) : undefined,
485
+ });
486
+ if (!r.ok) {
487
+ if (r.needsConfig) {
488
+ toast.message("Configurez l'URL n8n");
489
+ router.push("/configuration");
490
+ return;
491
+ }
492
+ toast.error(r.error);
493
+ }
494
+ }
495
+
496
+ return (
497
+ <button
498
+ type="button"
499
+ onClick={() => void openN8n()}
500
+ {...aidProps("nav.n8n")}
501
+ title={collapsed ? "n8n" : undefined}
502
+ className={cn(
503
+ "flex w-full items-center rounded-lg text-sm transition-colors",
504
+ nested
505
+ ? "gap-2.5 px-2.5 py-1.5 text-slate-400 hover:bg-slate-800/60 hover:text-white"
506
+ : collapsed
507
+ ? "h-9 justify-center text-slate-300 hover:bg-slate-800/60 hover:text-white"
508
+ : "gap-3 px-3 py-2 text-slate-300 hover:bg-slate-800/60 hover:text-white",
509
+ )}
510
+ >
511
+ <Workflow
512
+ className={cn("shrink-0", nested ? "h-3.5 w-3.5" : "h-4 w-4")}
513
+ />
514
+ {collapsed && !nested ? <span className="sr-only">n8n</span> : "n8n"}
515
+ </button>
516
+ );
517
+ }
518
+
519
+ function PluginSidebarLinks({
520
+ host,
521
+ me,
522
+ collapsed,
523
+ onNavigate,
524
+ }: {
525
+ host: SidebarHost;
526
+ me: any;
527
+ collapsed?: boolean;
528
+ onNavigate?: () => void;
529
+ }) {
530
+ const [plugins, setPlugins] = useState<
531
+ Array<{ id: string; label: string; href?: string }>
532
+ >([]);
533
+
534
+ useEffect(() => {
535
+ const pluginHost = host.plugins;
536
+ if (!pluginHost) return;
537
+ let cancelled = false;
538
+ const refresh = () => {
539
+ void pluginHost
540
+ .fetchVisible()
541
+ .then((items) => {
542
+ if (!cancelled) setPlugins(items);
543
+ })
544
+ .catch(() => {
545
+ /* keep last known plugin list */
546
+ });
547
+ };
548
+ refresh();
549
+ window.addEventListener(pluginHost.changedEvent, refresh);
550
+ window.addEventListener("focus", refresh);
551
+ const interval = window.setInterval(refresh, 5000);
552
+ return () => {
553
+ cancelled = true;
554
+ window.removeEventListener(pluginHost.changedEvent, refresh);
555
+ window.removeEventListener("focus", refresh);
556
+ window.clearInterval(interval);
557
+ };
558
+ }, [host.plugins]);
559
+
560
+ if (!plugins.length) return null;
561
+
562
+ function openPlugin(plugin: { id: string; label: string; href?: string }) {
563
+ onNavigate?.();
564
+ try {
565
+ host.plugins?.openPanel(plugin.id, { collapsed, me, href: plugin.href });
566
+ } catch (e) {
567
+ toast.error(e instanceof Error ? e.message : "Plugin indisponible");
568
+ }
569
+ }
570
+
571
+ return (
572
+ <>
573
+ {!collapsed ? (
574
+ <div className="px-3 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-500">
575
+ Plugins
576
+ </div>
577
+ ) : null}
578
+ {plugins.map((plugin) => (
579
+ <button
580
+ key={plugin.id}
581
+ type="button"
582
+ onClick={() => openPlugin(plugin)}
583
+ {...aidProps(`nav.plugin-${plugin.id}`)}
584
+ title={collapsed ? plugin.label : undefined}
585
+ className={cn(
586
+ "flex w-full items-center rounded-lg text-sm text-slate-300 transition-colors hover:bg-slate-800/60 hover:text-white",
587
+ collapsed ? "h-9 justify-center" : "gap-3 px-3 py-2",
588
+ )}
589
+ >
590
+ <Puzzle className="h-4 w-4 shrink-0" />
591
+ {collapsed ? (
592
+ <span className="sr-only">{plugin.label}</span>
593
+ ) : (
594
+ <span className="truncate">{plugin.label}</span>
595
+ )}
596
+ </button>
597
+ ))}
598
+ </>
599
+ );
600
+ }
601
+
602
+ const EMPTY_SIDEBAR_ACTIONS: SidebarActionItem[] = [];
603
+
604
+ /**
605
+ * Entrées d'action de la sidebar (sans navigation — ex. « Visite
606
+ * guidée ») : items du host de marque (`getActionItems`) + providers
607
+ * kit enregistrés (`registerSidebarActionsProvider`), filtrés par
608
+ * permission comme les entrées nav. La sidebar n'existant que dans le
609
+ * workspace authentifié, ces entrées ne sont jamais visibles sur les
610
+ * pages publiques (/login, /lp…).
611
+ */
612
+ function SidebarActionLinks({
613
+ host,
614
+ me,
615
+ collapsed,
616
+ onNavigate,
617
+ }: {
618
+ host: SidebarHost;
619
+ me: any;
620
+ collapsed?: boolean;
621
+ onNavigate?: () => void;
622
+ }) {
623
+ const registered = useSyncExternalStore(
624
+ subscribeSidebarActions,
625
+ getSidebarActionsSnapshot,
626
+ () => EMPTY_SIDEBAR_ACTIONS,
627
+ );
628
+ const items = useMemo(
629
+ () =>
630
+ [...(host.getActionItems?.() ?? []), ...registered].filter((item) =>
631
+ hasItemPermission(item, me),
632
+ ),
633
+ [host, me, registered],
634
+ );
635
+ if (!items.length) return null;
636
+ return (
637
+ <>
638
+ {items.map((item) => {
639
+ const Icon = item.icon;
640
+ return (
641
+ <button
642
+ key={item.id}
643
+ type="button"
644
+ onClick={() => {
645
+ item.onSelect();
646
+ onNavigate?.();
647
+ }}
648
+ {...aidProps(`nav.${aidSlug(item.label)}`)}
649
+ title={collapsed ? item.label : undefined}
650
+ className={cn(
651
+ "flex w-full items-center rounded-lg text-sm transition-colors",
652
+ collapsed ? "h-9 justify-center" : "gap-3 px-3 py-2",
653
+ "text-slate-300 hover:bg-slate-800/60 hover:text-white",
654
+ )}
655
+ >
656
+ <Icon className="h-4 w-4 shrink-0" />
657
+ {collapsed ? (
658
+ <span className="sr-only">{item.label}</span>
659
+ ) : (
660
+ <span className="truncate">{item.label}</span>
661
+ )}
662
+ </button>
663
+ );
664
+ })}
665
+ </>
666
+ );
667
+ }
668
+
669
+ function NavLinks({
670
+ host,
671
+ pathname,
672
+ search,
673
+ collapsed,
674
+ onNavigate,
675
+ }: {
676
+ host: SidebarHost;
677
+ pathname: string;
678
+ search: string;
679
+ collapsed?: boolean;
680
+ onNavigate?: () => void;
681
+ }) {
682
+ const { me } = useSession();
683
+ const forced = host.resolveForcedActiveHref?.(pathname, search) ?? null;
684
+ const navItems = host.getNavItems();
685
+
686
+ return (
687
+ <nav
688
+ className={cn(
689
+ "sidebar-scroll min-h-0 flex-1 space-y-0.5 overflow-y-auto py-4",
690
+ collapsed ? "px-2.5" : "px-3",
691
+ )}
692
+ >
693
+ {navItems.map((item: SidebarNavItem) => {
694
+ const path = hrefPath(item.href);
695
+ if (!hasItemPermission(item, me)) return null;
696
+ if (!canShowHref(host, path, me)) return null;
697
+ const active =
698
+ forced != null ? path === forced : isActiveHref(pathname, item.href);
699
+ const Icon = item.icon;
700
+ return (
701
+ <Link
702
+ key={item.href}
703
+ href={item.href}
704
+ onClick={onNavigate}
705
+ {...aidProps(`nav.${aidSlug(item.label)}`)}
706
+ title={collapsed ? item.label : undefined}
707
+ className={cn(
708
+ "flex items-center rounded-lg text-sm transition-colors",
709
+ collapsed ? "h-9 justify-center" : "gap-3 px-3 py-2",
710
+ active
711
+ ? "bg-slate-800 text-white"
712
+ : "text-slate-300 hover:bg-slate-800/60 hover:text-white",
713
+ )}
714
+ >
715
+ <Icon className="h-4 w-4 shrink-0" />
716
+ {collapsed ? (
717
+ <span className="sr-only">{item.label}</span>
718
+ ) : (
719
+ item.label
720
+ )}
721
+ </Link>
722
+ );
723
+ })}
724
+ {host.renderPlugins ? (
725
+ host.renderPlugins({ collapsed: Boolean(collapsed), me })
726
+ ) : (
727
+ <PluginSidebarLinks
728
+ host={host}
729
+ me={me}
730
+ collapsed={collapsed}
731
+ onNavigate={onNavigate}
732
+ />
733
+ )}
734
+ <AdminNavGroup
735
+ host={host}
736
+ me={me}
737
+ pathname={pathname}
738
+ collapsed={collapsed}
739
+ onNavigate={onNavigate}
740
+ />
741
+ <SidebarActionLinks
742
+ host={host}
743
+ me={me}
744
+ collapsed={collapsed}
745
+ onNavigate={onNavigate}
746
+ />
747
+ </nav>
748
+ );
749
+ }
750
+
751
+ function BrandHeader({
752
+ host,
753
+ collapsed,
754
+ onToggleCollapse,
755
+ showClose,
756
+ onClose,
757
+ closeRef,
758
+ }: {
759
+ host: SidebarHost;
760
+ collapsed?: boolean;
761
+ onToggleCollapse?: () => void;
762
+ showClose?: boolean;
763
+ onClose?: () => void;
764
+ closeRef?: RefObject<HTMLButtonElement>;
765
+ }) {
766
+ const productName = getShellUiBrand().productName || "Creezio";
767
+
768
+ if (collapsed) {
769
+ return (
770
+ <div className="flex h-[57px] shrink-0 items-center justify-center border-b border-slate-800">
771
+ <button
772
+ type="button"
773
+ onClick={onToggleCollapse}
774
+ aria-label="Déplier la barre latérale"
775
+ title="Déplier la barre latérale"
776
+ className="group relative flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-slate-800"
777
+ >
778
+ <BrandMark host={host} className="transition-opacity group-hover:opacity-0" />
779
+ <PanelLeftOpen className="absolute h-4 w-4 text-slate-200 opacity-0 transition-opacity group-hover:opacity-100" />
780
+ </button>
781
+ </div>
782
+ );
783
+ }
784
+
785
+ return (
786
+ <div className="flex h-[57px] shrink-0 items-center justify-between gap-2 border-b border-slate-800 pl-4 pr-2">
787
+ <div className="flex min-w-0 items-center gap-2.5">
788
+ <BrandMark host={host} />
789
+ <div className="truncate text-sm font-semibold tracking-[0.18em] text-white">
790
+ {productName.toUpperCase()}
791
+ </div>
792
+ </div>
793
+ <div className="flex shrink-0 items-center">
794
+ {onToggleCollapse ? (
795
+ <Button
796
+ type="button"
797
+ variant="ghost"
798
+ size="icon"
799
+ onClick={onToggleCollapse}
800
+ aria-label="Réduire la barre latérale"
801
+ title="Réduire la barre latérale"
802
+ className="h-8 w-8 text-slate-400 hover:bg-slate-800 hover:text-white"
803
+ >
804
+ <PanelLeftClose className="h-4 w-4" />
805
+ </Button>
806
+ ) : null}
807
+ {showClose ? (
808
+ <Button
809
+ ref={closeRef}
810
+ type="button"
811
+ variant="ghost"
812
+ size="icon"
813
+ onClick={onClose}
814
+ aria-label="Fermer le menu"
815
+ className="h-8 w-8 text-slate-300 hover:bg-slate-800 hover:text-white"
816
+ >
817
+ <X className="h-4 w-4" />
818
+ </Button>
819
+ ) : null}
820
+ </div>
821
+ </div>
822
+ );
823
+ }
824
+
825
+ function initialsFromUsername(name: string): string {
826
+ const parts = name.trim().split(/[\s._-]+/).filter(Boolean);
827
+ if (parts.length >= 2) {
828
+ return ((parts[0]?.[0] || "") + (parts[1]?.[0] || "")).toUpperCase();
829
+ }
830
+ return name.slice(0, 2).toUpperCase() || "?";
831
+ }
832
+
833
+ function SidebarFooter({
834
+ host,
835
+ collapsed,
836
+ }: {
837
+ host: SidebarHost;
838
+ collapsed?: boolean;
839
+ }) {
840
+ const { me, impersonate } = useSession();
841
+ const [username, setUsername] = useState<string | null>(null);
842
+ const [collabs, setCollabs] = useState<
843
+ Array<{
844
+ id: string;
845
+ username: string;
846
+ kind: "human" | "ai";
847
+ active: boolean;
848
+ role: string;
849
+ }>
850
+ >([]);
851
+ const [menuOpen, setMenuOpen] = useState(false);
852
+
853
+ useEffect(() => {
854
+ let cancelled = false;
855
+ async function load() {
856
+ try {
857
+ const desktopUser = await getShellDesktopApi()?.getAccount?.();
858
+ if (!cancelled && desktopUser?.username) {
859
+ setUsername(desktopUser.username);
860
+ return;
861
+ }
862
+ } catch {
863
+ /* ignore */
864
+ }
865
+ try {
866
+ const res = await fetch("/api/v1/auth/me");
867
+ if (!res.ok) return;
868
+ const data = (await res.json()) as { user?: string };
869
+ if (!cancelled && data.user) setUsername(data.user);
870
+ } catch {
871
+ /* ignore */
872
+ }
873
+ }
874
+ void load();
875
+ return () => {
876
+ cancelled = true;
877
+ };
878
+ }, []);
879
+
880
+ useEffect(() => {
881
+ if (me?.role !== "owner" || me.impersonating) return;
882
+ void fetch("/api/v1/users")
883
+ .then((r) => (r.ok ? r.json() : null))
884
+ .then((data: { users?: typeof collabs } | null) => {
885
+ if (data?.users) {
886
+ setCollabs(
887
+ data.users.filter((u) => u.role === "collaborator" && u.active),
888
+ );
889
+ }
890
+ })
891
+ .catch(() => {});
892
+ }, [me?.role, me?.impersonating]);
893
+
894
+ async function viewAs(user: (typeof collabs)[number]) {
895
+ setMenuOpen(false);
896
+ if (user.kind === "ai" && getShellDesktopApi()?.showAiWorkspace) {
897
+ await openAiWorkspaceView(user.id, user.username);
898
+ return;
899
+ }
900
+ await impersonate(user.id);
901
+ }
902
+
903
+ async function logout() {
904
+ const desktop = getShellDesktopApi();
905
+ if (desktop?.logout) {
906
+ try {
907
+ await desktop.logout();
908
+ } catch {
909
+ /* ignore */
910
+ }
911
+ }
912
+ try {
913
+ await fetch("/api/v1/auth/logout", { method: "POST" });
914
+ } catch {
915
+ /* ignore */
916
+ }
917
+ try {
918
+ await host.onLogoutExtra?.();
919
+ } catch {
920
+ /* ignore */
921
+ }
922
+ if (desktop?.rechooseConnection) {
923
+ try {
924
+ await desktop.rechooseConnection();
925
+ return;
926
+ } catch {
927
+ /* fallback */
928
+ }
929
+ }
930
+ window.location.href = "/login";
931
+ }
932
+
933
+ const label = username || "Compte";
934
+ const initials = username ? initialsFromUsername(username) : "?";
935
+
936
+ if (collapsed) {
937
+ return (
938
+ <div className="flex shrink-0 flex-col items-center gap-1.5 border-t border-slate-800 px-2.5 py-3">
939
+ <Avatar className="h-8 w-8 border border-slate-700 bg-slate-800" title={label}>
940
+ <AvatarFallback className="bg-slate-800 text-xs font-semibold text-slate-100">
941
+ {initials}
942
+ </AvatarFallback>
943
+ </Avatar>
944
+ <Button
945
+ type="button"
946
+ variant="ghost"
947
+ size="icon"
948
+ onClick={() => void logout()}
949
+ title="Déconnexion"
950
+ aria-label="Déconnexion"
951
+ className="h-8 w-8 text-slate-400 hover:bg-slate-800 hover:text-white"
952
+ >
953
+ <LogOut className="h-4 w-4" />
954
+ </Button>
955
+ </div>
956
+ );
957
+ }
958
+
959
+ return (
960
+ <div className="relative shrink-0 space-y-3 border-t border-slate-800 px-3 py-3">
961
+ {me?.role === "owner" && !me.impersonating && collabs.length > 0 ? (
962
+ <div className="relative">
963
+ <Button
964
+ type="button"
965
+ variant="ghost"
966
+ size="sm"
967
+ className="h-8 w-full justify-start gap-2 text-slate-300 hover:bg-slate-800 hover:text-white"
968
+ onClick={() => setMenuOpen((v) => !v)}
969
+ >
970
+ <Eye className="h-3.5 w-3.5" /> Voir comme...
971
+ </Button>
972
+ {menuOpen ? (
973
+ <div className="absolute bottom-full left-0 right-0 z-50 mb-1 max-h-48 overflow-y-auto rounded-lg border border-slate-700 bg-slate-900 py-1 shadow-xl">
974
+ {collabs.map((u) => (
975
+ <button
976
+ key={u.id}
977
+ type="button"
978
+ className="flex w-full px-3 py-2 text-left text-sm text-slate-200 hover:bg-slate-800"
979
+ onClick={() => void viewAs(u)}
980
+ >
981
+ {u.kind === "ai" ? `IA - ${u.username}` : u.username}
982
+ </button>
983
+ ))}
984
+ </div>
985
+ ) : null}
986
+ </div>
987
+ ) : null}
988
+ <div className="flex items-center justify-between gap-2">
989
+ <div className="flex min-w-0 items-center gap-2.5">
990
+ <Avatar className="h-8 w-8 border border-slate-700 bg-slate-800">
991
+ <AvatarFallback className="bg-slate-800 text-xs font-semibold text-slate-100">
992
+ {initials}
993
+ </AvatarFallback>
994
+ </Avatar>
995
+ <span className="truncate text-sm font-medium text-slate-200">{label}</span>
996
+ </div>
997
+ <Button
998
+ type="button"
999
+ variant="ghost"
1000
+ size="icon"
1001
+ onClick={() => void logout()}
1002
+ title="Déconnexion"
1003
+ aria-label="Déconnexion"
1004
+ className="h-8 w-8 shrink-0 text-slate-400 hover:bg-slate-800 hover:text-white"
1005
+ >
1006
+ <LogOut className="h-4 w-4" />
1007
+ </Button>
1008
+ </div>
1009
+ </div>
1010
+ );
1011
+ }
1012
+
1013
+ function SidebarContent({
1014
+ host,
1015
+ pathname,
1016
+ search,
1017
+ collapsed,
1018
+ onNavigate,
1019
+ }: {
1020
+ host: SidebarHost;
1021
+ pathname: string;
1022
+ search: string;
1023
+ collapsed?: boolean;
1024
+ onNavigate?: () => void;
1025
+ }) {
1026
+ return (
1027
+ <>
1028
+ <NavLinks
1029
+ host={host}
1030
+ pathname={pathname}
1031
+ search={search}
1032
+ collapsed={collapsed}
1033
+ onNavigate={onNavigate}
1034
+ />
1035
+ <SidebarFooter host={host} collapsed={collapsed} />
1036
+ </>
1037
+ );
1038
+ }
1039
+
1040
+ export function Sidebar({
1041
+ collapsed = false,
1042
+ onToggleCollapse,
1043
+ mobileOpen = false,
1044
+ onMobileClose,
1045
+ }: {
1046
+ collapsed?: boolean;
1047
+ onToggleCollapse?: () => void;
1048
+ mobileOpen?: boolean;
1049
+ onMobileClose?: () => void;
1050
+ }) {
1051
+ const hostVersion = useSyncExternalStore(
1052
+ subscribeSidebarHost,
1053
+ getSidebarHostVersion,
1054
+ getSidebarHostVersion,
1055
+ );
1056
+ void hostVersion;
1057
+ const host = getSidebarHost();
1058
+ const pathname = usePathname() || "/";
1059
+ const search = useLocationSearch(pathname);
1060
+ const titleId = useId();
1061
+ const closeRef = useRef<HTMLButtonElement>(null);
1062
+ const prevPathRef = useRef(pathname);
1063
+
1064
+ useEffect(() => {
1065
+ if (prevPathRef.current !== pathname) {
1066
+ prevPathRef.current = pathname;
1067
+ if (mobileOpen) onMobileClose?.();
1068
+ }
1069
+ }, [pathname, mobileOpen, onMobileClose]);
1070
+
1071
+ useEffect(() => {
1072
+ if (!mobileOpen) return;
1073
+
1074
+ function onKey(e: KeyboardEvent) {
1075
+ if (e.key === "Escape") onMobileClose?.();
1076
+ }
1077
+
1078
+ const prevOverflow = document.body.style.overflow;
1079
+ document.body.style.overflow = "hidden";
1080
+ document.addEventListener("keydown", onKey);
1081
+ const t = window.setTimeout(() => closeRef.current?.focus(), 0);
1082
+
1083
+ return () => {
1084
+ document.body.style.overflow = prevOverflow;
1085
+ document.removeEventListener("keydown", onKey);
1086
+ window.clearTimeout(t);
1087
+ };
1088
+ }, [mobileOpen, onMobileClose]);
1089
+
1090
+ return (
1091
+ <>
1092
+ <aside
1093
+ className={cn(
1094
+ "fixed inset-y-0 left-0 z-30 hidden h-[100dvh] flex-col bg-slate-900 text-slate-100 md:flex",
1095
+ "transition-[width] duration-200 ease-out",
1096
+ collapsed ? "w-16" : "w-64",
1097
+ )}
1098
+ >
1099
+ <BrandHeader
1100
+ host={host}
1101
+ collapsed={collapsed}
1102
+ onToggleCollapse={onToggleCollapse}
1103
+ />
1104
+ <SidebarContent
1105
+ host={host}
1106
+ pathname={pathname}
1107
+ search={search}
1108
+ collapsed={collapsed}
1109
+ />
1110
+ </aside>
1111
+
1112
+ {mobileOpen ? (
1113
+ <div className="fixed inset-0 z-40 md:hidden">
1114
+ <button
1115
+ type="button"
1116
+ aria-label="Fermer le menu"
1117
+ onClick={onMobileClose}
1118
+ className="absolute inset-0 bg-black/50"
1119
+ />
1120
+ <aside
1121
+ id="mobile-nav"
1122
+ role="dialog"
1123
+ aria-modal="true"
1124
+ aria-labelledby={titleId}
1125
+ className="absolute inset-y-0 left-0 flex h-[100dvh] w-64 max-w-[85vw] flex-col bg-slate-900 text-slate-100 shadow-xl"
1126
+ >
1127
+ <span id={titleId} className="sr-only">
1128
+ Menu de navigation
1129
+ </span>
1130
+ <BrandHeader
1131
+ host={host}
1132
+ showClose
1133
+ onClose={onMobileClose}
1134
+ closeRef={closeRef}
1135
+ />
1136
+ <SidebarContent
1137
+ host={host}
1138
+ pathname={pathname}
1139
+ search={search}
1140
+ onNavigate={onMobileClose}
1141
+ />
1142
+ </aside>
1143
+ </div>
1144
+ ) : null}
1145
+ </>
1146
+ );
1147
+ }
1148
+
1149
+ /** @deprecated Use Sidebar. */
1150
+ export const CrmSidebar = Sidebar;