@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.
- package/README.md +336 -0
- package/dist/adapters/nav-shell.d.ts +54 -0
- package/dist/adapters/nav-shell.d.ts.map +1 -0
- package/dist/adapters/nav-shell.js +112 -0
- package/dist/adapters/nav-shell.js.map +1 -0
- package/dist/brand.d.ts +64 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +59 -0
- package/dist/brand.js.map +1 -0
- package/dist/core-nav.d.ts +17 -0
- package/dist/core-nav.d.ts.map +1 -0
- package/dist/core-nav.js +40 -0
- package/dist/core-nav.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-scopes.d.ts +24 -0
- package/dist/lib/api-scopes.d.ts.map +1 -0
- package/dist/lib/api-scopes.js +78 -0
- package/dist/lib/api-scopes.js.map +1 -0
- package/dist/lib/catalog-suspense-key.d.ts +3 -0
- package/dist/lib/catalog-suspense-key.d.ts.map +1 -0
- package/dist/lib/catalog-suspense-key.js +12 -0
- package/dist/lib/catalog-suspense-key.js.map +1 -0
- package/dist/lib/data-changed.d.ts +51 -0
- package/dist/lib/data-changed.d.ts.map +1 -0
- package/dist/lib/data-changed.js +227 -0
- package/dist/lib/data-changed.js.map +1 -0
- package/dist/lib/desktop-home-path.d.ts +14 -0
- package/dist/lib/desktop-home-path.d.ts.map +1 -0
- package/dist/lib/desktop-home-path.js +29 -0
- package/dist/lib/desktop-home-path.js.map +1 -0
- package/dist/lib/geo-distance.d.ts +6 -0
- package/dist/lib/geo-distance.d.ts.map +1 -0
- package/dist/lib/geo-distance.js +27 -0
- package/dist/lib/geo-distance.js.map +1 -0
- package/dist/lib/img.d.ts +2 -0
- package/dist/lib/img.d.ts.map +1 -0
- package/dist/lib/img.js +40 -0
- package/dist/lib/img.js.map +1 -0
- package/dist/lib/keepalive-eviction.d.ts +14 -0
- package/dist/lib/keepalive-eviction.d.ts.map +1 -0
- package/dist/lib/keepalive-eviction.js +44 -0
- package/dist/lib/keepalive-eviction.js.map +1 -0
- package/dist/lib/ops-track.d.ts +29 -0
- package/dist/lib/ops-track.d.ts.map +1 -0
- package/dist/lib/ops-track.js +37 -0
- package/dist/lib/ops-track.js.map +1 -0
- package/dist/lib/optimize-cover-url.d.ts +10 -0
- package/dist/lib/optimize-cover-url.d.ts.map +1 -0
- package/dist/lib/optimize-cover-url.js +80 -0
- package/dist/lib/optimize-cover-url.js.map +1 -0
- package/dist/lib/page-trails.d.ts +9 -0
- package/dist/lib/page-trails.d.ts.map +1 -0
- package/dist/lib/page-trails.js +20 -0
- package/dist/lib/page-trails.js.map +1 -0
- package/dist/lib/public-origin.d.ts +32 -0
- package/dist/lib/public-origin.d.ts.map +1 -0
- package/dist/lib/public-origin.js +125 -0
- package/dist/lib/public-origin.js.map +1 -0
- package/dist/lib/server-incident.d.ts +12 -0
- package/dist/lib/server-incident.d.ts.map +1 -0
- package/dist/lib/server-incident.js +72 -0
- package/dist/lib/server-incident.js.map +1 -0
- package/dist/lib/tab-document-url.d.ts +13 -0
- package/dist/lib/tab-document-url.d.ts.map +1 -0
- package/dist/lib/tab-document-url.js +60 -0
- package/dist/lib/tab-document-url.js.map +1 -0
- package/dist/lib/utils.d.ts +20 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +87 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/nav-catalog.d.ts +97 -0
- package/dist/nav-catalog.d.ts.map +1 -0
- package/dist/nav-catalog.js +282 -0
- package/dist/nav-catalog.js.map +1 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +60 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist-cjs/adapters/nav-shell.js +115 -0
- package/dist-cjs/adapters/nav-shell.js.map +1 -0
- package/dist-cjs/brand.js +66 -0
- package/dist-cjs/brand.js.map +1 -0
- package/dist-cjs/core-nav.js +43 -0
- package/dist-cjs/core-nav.js.map +1 -0
- package/dist-cjs/index.js +95 -0
- package/dist-cjs/index.js.map +1 -0
- package/dist-cjs/lib/api-scopes.js +86 -0
- package/dist-cjs/lib/api-scopes.js.map +1 -0
- package/dist-cjs/lib/catalog-suspense-key.js +15 -0
- package/dist-cjs/lib/catalog-suspense-key.js.map +1 -0
- package/dist-cjs/lib/data-changed.js +236 -0
- package/dist-cjs/lib/data-changed.js.map +1 -0
- package/dist-cjs/lib/desktop-home-path.js +34 -0
- package/dist-cjs/lib/desktop-home-path.js.map +1 -0
- package/dist-cjs/lib/geo-distance.js +30 -0
- package/dist-cjs/lib/geo-distance.js.map +1 -0
- package/dist-cjs/lib/img.js +43 -0
- package/dist-cjs/lib/img.js.map +1 -0
- package/dist-cjs/lib/keepalive-eviction.js +49 -0
- package/dist-cjs/lib/keepalive-eviction.js.map +1 -0
- package/dist-cjs/lib/ops-track.js +41 -0
- package/dist-cjs/lib/ops-track.js.map +1 -0
- package/dist-cjs/lib/optimize-cover-url.js +83 -0
- package/dist-cjs/lib/optimize-cover-url.js.map +1 -0
- package/dist-cjs/lib/page-trails.js +25 -0
- package/dist-cjs/lib/page-trails.js.map +1 -0
- package/dist-cjs/lib/public-origin.js +130 -0
- package/dist-cjs/lib/public-origin.js.map +1 -0
- package/dist-cjs/lib/server-incident.js +75 -0
- package/dist-cjs/lib/server-incident.js.map +1 -0
- package/dist-cjs/lib/tab-document-url.js +65 -0
- package/dist-cjs/lib/tab-document-url.js.map +1 -0
- package/dist-cjs/lib/utils.js +97 -0
- package/dist-cjs/lib/utils.js.map +1 -0
- package/dist-cjs/nav-catalog.js +293 -0
- package/dist-cjs/nav-catalog.js.map +1 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/registry.js +64 -0
- package/dist-cjs/registry.js.map +1 -0
- package/dist-cjs/types.js +3 -0
- package/dist-cjs/types.js.map +1 -0
- package/package.json +163 -0
- package/ui/app-error-boundary.tsx +59 -0
- package/ui/data-table.tsx +121 -0
- package/ui/desktop/auth-window-chrome.tsx +51 -0
- package/ui/desktop/desktop-bridge.tsx +87 -0
- package/ui/desktop/external-site-slot.tsx +313 -0
- package/ui/desktop/external-site-surface.ts +58 -0
- package/ui/desktop/site-link.tsx +105 -0
- package/ui/desktop/window-chrome-controls.tsx +5 -0
- package/ui/desktop-types.ts +30 -0
- package/ui/faceted-filters.tsx +119 -0
- package/ui/global-search-host.ts +20 -0
- package/ui/global-search.tsx +66 -0
- package/ui/index.ts +178 -0
- package/ui/kit.ts +30 -0
- package/ui/layout/app-shell.tsx +58 -0
- package/ui/layout/desktop-update-banner.tsx +100 -0
- package/ui/layout/entity-header.tsx +44 -0
- package/ui/layout/native-os-nav.ts +104 -0
- package/ui/layout/nav-catalog-loader.tsx +98 -0
- package/ui/layout/nav-icons.ts +67 -0
- package/ui/layout/page-chrome.tsx +90 -0
- package/ui/layout/page-toolbar-context.tsx +82 -0
- package/ui/layout/sandbox-banner.tsx +38 -0
- package/ui/layout/section-view-shell.tsx +73 -0
- package/ui/layout/sidebar-host.ts +144 -0
- package/ui/layout/sidebar.tsx +1150 -0
- package/ui/lib/ai-screencast-hub.ts +127 -0
- package/ui/lib/ai-workspace-client.ts +46 -0
- package/ui/lib/aid.ts +22 -0
- package/ui/lib/desktop-host.ts +114 -0
- package/ui/lib/fleet-tracker-client.ts +237 -0
- package/ui/lib/hermes-ui.ts +114 -0
- package/ui/lib/n8n-ui.ts +115 -0
- package/ui/lib/use-creezio-resource.tsx +121 -0
- package/ui/lib/use-shell-ui-brand.ts +22 -0
- package/ui/list-toolbar.tsx +309 -0
- package/ui/os-pages/desktop-settings-page.tsx +158 -0
- package/ui/os-pages/index.ts +8 -0
- package/ui/page-loading/entity-page-loading.tsx +59 -0
- package/ui/page-loading/list-page-loading.tsx +61 -0
- package/ui/pagination.tsx +56 -0
- package/ui/primitives/avatar.tsx +33 -0
- package/ui/primitives/badge.tsx +36 -0
- package/ui/primitives/breadcrumb.tsx +93 -0
- package/ui/primitives/button.tsx +44 -0
- package/ui/primitives/card.tsx +44 -0
- package/ui/primitives/chart.tsx +317 -0
- package/ui/primitives/command.tsx +111 -0
- package/ui/primitives/dialog.tsx +100 -0
- package/ui/primitives/dropdown-menu.tsx +187 -0
- package/ui/primitives/input.tsx +19 -0
- package/ui/primitives/label.tsx +17 -0
- package/ui/primitives/resizable.tsx +45 -0
- package/ui/primitives/scroll-area.tsx +41 -0
- package/ui/primitives/select.tsx +73 -0
- package/ui/primitives/separator.tsx +26 -0
- package/ui/primitives/sheet.tsx +90 -0
- package/ui/primitives/skeleton.tsx +7 -0
- package/ui/primitives/sonner.tsx +22 -0
- package/ui/primitives/tabs.tsx +51 -0
- package/ui/primitives/textarea.tsx +22 -0
- package/ui/primitives/tooltip.tsx +31 -0
- package/ui/pwa/client-error-reporter.tsx +62 -0
- package/ui/pwa/register-sw.tsx +50 -0
- package/ui/range-filters.tsx +174 -0
- package/ui/search/global-search-config.ts +32 -0
- package/ui/search/global-search-provider.tsx +528 -0
- package/ui/search/search-history.ts +74 -0
- package/ui/search-input.tsx +146 -0
- package/ui/settings/account-settings.tsx +140 -0
- package/ui/settings/agent-profile-settings.tsx +184 -0
- package/ui/settings/api-keys-settings.tsx +349 -0
- package/ui/settings/desktop-background-settings.tsx +128 -0
- package/ui/settings/desktop-connection-settings.tsx +251 -0
- package/ui/settings/desktop-embed-env-panel.tsx +257 -0
- package/ui/settings/desktop-fleet-telemetry-settings.tsx +269 -0
- package/ui/settings/desktop-hermes-settings.tsx +341 -0
- package/ui/settings/desktop-llm-keys.tsx +192 -0
- package/ui/settings/desktop-n8n-settings.tsx +306 -0
- package/ui/settings/desktop-tunnel.tsx +264 -0
- package/ui/settings/desktop-update-settings.tsx +175 -0
- package/ui/settings/factory-reset-settings.tsx +89 -0
- package/ui/settings/host-only-settings.tsx +62 -0
- package/ui/settings/locked-config-field.tsx +30 -0
- package/ui/settings/ops-diagnostic-settings.tsx +242 -0
- package/ui/settings/search-reindex-settings.tsx +158 -0
- package/ui/settings/server-mode-cards.tsx +801 -0
- package/ui/theme/tailwind-preset.cjs +82 -0
- package/ui/theme/theme.css +328 -0
- package/ui/workspace/ai-activity-panel-host.tsx +19 -0
- package/ui/workspace/ai-workspace-agent-host.tsx +54 -0
- package/ui/workspace/ai-workspace-banner.tsx +168 -0
- package/ui/workspace/impersonation-banner.tsx +39 -0
- package/ui/workspace/keep-alive.tsx +220 -0
- package/ui/workspace/tab-workspace-context.tsx +1172 -0
- package/ui/workspace/tab-workspace-host.ts +89 -0
- package/ui/workspace/types.ts +500 -0
- package/ui/workspace/use-location-search.tsx +50 -0
- package/ui/workspace/window-chrome-controls.tsx +116 -0
- package/ui/workspace/workspace-config.ts +47 -0
- package/ui/workspace/workspace-root.tsx +147 -0
- package/ui/workspace/workspace-shell.tsx +292 -0
- 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;
|