@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,528 @@
1
+ "use client";
2
+ import { configureGlobalSearchHost } from "../global-search-host";
3
+
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
13
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
14
+ import { useTabWorkspaceOptional } from "../workspace/tab-workspace-context";
15
+ import { mirrorFleetAction } from "../lib/desktop-host";
16
+ import { useRouter } from "next/navigation";
17
+ import {
18
+ ArrowRight,
19
+ Clock,
20
+ CornerDownLeft,
21
+ History,
22
+ Loader2,
23
+ Plus,
24
+ Search,
25
+ X,
26
+ } from "lucide-react";
27
+ import {
28
+ Command,
29
+ CommandEmpty,
30
+ CommandGroup,
31
+ CommandInput,
32
+ CommandItem,
33
+ CommandList,
34
+ CommandSeparator,
35
+ } from "../primitives/command";
36
+ import {
37
+ loadSearchHistory,
38
+ pushRecentHit,
39
+ pushSearchQuery,
40
+ type SearchHistory,
41
+ } from "./search-history";
42
+ import { cn } from "@creezio/shell-ui";
43
+ import {
44
+ getGlobalSearchConfig,
45
+ type GlobalSearchConfig,
46
+ type GlobalSearchHit,
47
+ } from "./global-search-config";
48
+
49
+ type GlobalSearchContextValue = {
50
+ open: boolean;
51
+ setOpen: (open: boolean) => void;
52
+ /**
53
+ * Ouvre la recherche en mode « nouvel onglet » (façon Notion) : le résultat
54
+ * choisi s'ouvrira dans un nouvel onglet. Annuler (esc) ne crée rien.
55
+ */
56
+ openForNewTab: () => void;
57
+ };
58
+
59
+ const GlobalSearchContext = createContext<GlobalSearchContextValue | null>(null);
60
+
61
+ export function useGlobalSearch() {
62
+ const ctx = useContext(GlobalSearchContext);
63
+ if (!ctx) {
64
+ throw new Error("useGlobalSearch must be used within GlobalSearchProvider");
65
+ }
66
+ return ctx;
67
+ }
68
+
69
+ function hitKey(hit: GlobalSearchHit) {
70
+ return `${hit.index}:${hit.id}`;
71
+ }
72
+
73
+ function trackSearchEvent(
74
+ config: GlobalSearchConfig,
75
+ event: string,
76
+ payload: Record<string, unknown>,
77
+ ) {
78
+ if (config.onTrack) {
79
+ config.onTrack(event, payload);
80
+ return;
81
+ }
82
+ mirrorFleetAction({
83
+ type: event,
84
+ label: String(payload.label || event),
85
+ path: typeof payload.href === "string" ? payload.href : "/search",
86
+ meta: payload,
87
+ });
88
+ }
89
+
90
+ function groupHits(
91
+ hits: GlobalSearchHit[],
92
+ labels: Record<string, string>,
93
+ ): { index: string; label: string; hits: GlobalSearchHit[] }[] {
94
+ const map = new Map<string, GlobalSearchHit[]>();
95
+ for (const hit of hits) {
96
+ const list = map.get(hit.index) || [];
97
+ list.push(hit);
98
+ map.set(hit.index, list);
99
+ }
100
+ return Array.from(map.entries()).map(([index, groupHits]) => ({
101
+ index,
102
+ label: labels[index] || index,
103
+ hits: groupHits,
104
+ }));
105
+ }
106
+
107
+ function FilterChips({
108
+ hits,
109
+ active,
110
+ onChange,
111
+ labels,
112
+ }: {
113
+ hits: GlobalSearchHit[];
114
+ active: string | null;
115
+ onChange: (index: string | null) => void;
116
+ labels: Record<string, string>;
117
+ }) {
118
+ const counts = useMemo(() => {
119
+ const m = new Map<string, number>();
120
+ for (const h of hits) {
121
+ m.set(h.index, (m.get(h.index) || 0) + 1);
122
+ }
123
+ return m;
124
+ }, [hits]);
125
+
126
+ if (!hits.length) return null;
127
+
128
+ const chips = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
129
+
130
+ return (
131
+ <div className="flex shrink-0 gap-1.5 overflow-x-auto border-b border-slate-200 px-3 py-2">
132
+ <button
133
+ type="button"
134
+ onClick={() => onChange(null)}
135
+ className={cn(
136
+ "shrink-0 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
137
+ active === null
138
+ ? "bg-slate-900 text-white"
139
+ : "bg-slate-100 text-slate-600 hover:bg-slate-200",
140
+ )}
141
+ >
142
+ Tous
143
+ <span className="ml-1 opacity-70">{hits.length}</span>
144
+ </button>
145
+ {chips.map(([index, count]) => (
146
+ <button
147
+ key={index}
148
+ type="button"
149
+ onClick={() => onChange(active === index ? null : index)}
150
+ className={cn(
151
+ "shrink-0 rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
152
+ active === index
153
+ ? "bg-slate-900 text-white"
154
+ : "bg-slate-100 text-slate-600 hover:bg-slate-200",
155
+ )}
156
+ >
157
+ {labels[index] || index}
158
+ <span className="ml-1 opacity-70">{count}</span>
159
+ </button>
160
+ ))}
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function HitPreview({
166
+ hit,
167
+ labels,
168
+ }: {
169
+ hit: GlobalSearchHit | null;
170
+ labels: Record<string, string>;
171
+ }) {
172
+ if (!hit) {
173
+ return (
174
+ <div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-slate-400">
175
+ <Search className="h-8 w-8 opacity-40" />
176
+ <p>Sélectionnez un résultat pour l&apos;aperçu</p>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <div className="flex h-full flex-col p-5">
183
+ <span className="mb-2 inline-flex w-fit rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
184
+ {labels[hit.index] || hit.index}
185
+ </span>
186
+ <h3 className="text-lg font-semibold leading-snug text-slate-900">{hit.title}</h3>
187
+ {hit.subtitle ? (
188
+ <p className="mt-2 text-sm leading-relaxed text-slate-600">{hit.subtitle}</p>
189
+ ) : null}
190
+ <div className="mt-auto flex items-center gap-2 pt-6 text-xs text-slate-400">
191
+ <CornerDownLeft className="h-3.5 w-3.5" />
192
+ Ouvrir
193
+ <span className="mx-1">·</span>
194
+ <span className="truncate font-mono text-[11px]">{hit.href}</span>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ function GlobalSearchModal({
201
+ open,
202
+ onOpenChange,
203
+ newTabMode = false,
204
+ }: {
205
+ open: boolean;
206
+ onOpenChange: (open: boolean) => void;
207
+ /** Le résultat choisi s'ouvre dans un nouvel onglet (bouton + de la tab bar). */
208
+ newTabMode?: boolean;
209
+ }) {
210
+ const router = useRouter();
211
+ const workspace = useTabWorkspaceOptional();
212
+ const config = getGlobalSearchConfig();
213
+ const indexLabels = config.indexLabels || {};
214
+ const [q, setQ] = useState("");
215
+ const [hits, setHits] = useState<GlobalSearchHit[]>([]);
216
+ const [loading, setLoading] = useState(false);
217
+ const [filterIndex, setFilterIndex] = useState<string | null>(null);
218
+ const [history, setHistory] = useState<SearchHistory>({ queries: [], recent: [] });
219
+ const [selectedKey, setSelectedKey] = useState<string | null>(null);
220
+
221
+ const filteredHits = useMemo(
222
+ () => (filterIndex ? hits.filter((h) => h.index === filterIndex) : hits),
223
+ [hits, filterIndex],
224
+ );
225
+
226
+ const grouped = useMemo(
227
+ () => groupHits(filteredHits, indexLabels),
228
+ [filteredHits, indexLabels],
229
+ );
230
+
231
+ const flatHits = useMemo(() => grouped.flatMap((g) => g.hits), [grouped]);
232
+
233
+ const selectedHit = useMemo(() => {
234
+ if (!selectedKey) return flatHits[0] || null;
235
+ return flatHits.find((h) => hitKey(h) === selectedKey) || flatHits[0] || null;
236
+ }, [flatHits, selectedKey]);
237
+
238
+ useEffect(() => {
239
+ if (open) {
240
+ setHistory(loadSearchHistory());
241
+ setQ("");
242
+ setHits([]);
243
+ setFilterIndex(null);
244
+ setSelectedKey(null);
245
+ }
246
+ }, [open]);
247
+
248
+ useEffect(() => {
249
+ if (!open) return;
250
+ const trimmed = q.trim();
251
+ if (trimmed.length < 2) {
252
+ setHits([]);
253
+ setLoading(false);
254
+ return;
255
+ }
256
+ const controller = new AbortController();
257
+ const t = window.setTimeout(async () => {
258
+ setLoading(true);
259
+ try {
260
+ trackSearchEvent(config, "search", {
261
+ label: `Recherche "${trimmed}"`,
262
+ query: trimmed,
263
+ });
264
+ const next = await config.search(trimmed, controller.signal);
265
+ setHits(next);
266
+ setFilterIndex(null);
267
+ setSelectedKey(next[0] ? hitKey(next[0]) : null);
268
+ } catch (e) {
269
+ if ((e as { name?: string })?.name === "AbortError") return;
270
+ setHits([]);
271
+ setSelectedKey(null);
272
+ } finally {
273
+ setLoading(false);
274
+ }
275
+ }, 200);
276
+ return () => {
277
+ controller.abort();
278
+ window.clearTimeout(t);
279
+ };
280
+ }, [q, open, config]);
281
+
282
+ useEffect(() => {
283
+ if (flatHits.length && !flatHits.some((h) => hitKey(h) === selectedKey)) {
284
+ setSelectedKey(hitKey(flatHits[0]));
285
+ }
286
+ }, [flatHits, selectedKey]);
287
+
288
+ const navigateTo = useCallback(
289
+ (hit: GlobalSearchHit) => {
290
+ pushSearchQuery(q.trim());
291
+ pushRecentHit(hit);
292
+ trackSearchEvent(config, "search_open", {
293
+ label: `Ouverture "${hit.title || hit.href}"`,
294
+ href: hit.href,
295
+ query: q.trim(),
296
+ index: hit.index,
297
+ });
298
+ onOpenChange(false);
299
+ if (workspace) {
300
+ workspace.navigate(hit.href, {
301
+ newTab: newTabMode,
302
+ preferCatalogue: config.preferCatalogueHref?.(hit.href) ?? false,
303
+ });
304
+ } else {
305
+ router.push(hit.href);
306
+ }
307
+ },
308
+ [q, onOpenChange, router, workspace, newTabMode, config],
309
+ );
310
+
311
+ const showHistory = q.trim().length < 2;
312
+
313
+ return (
314
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
315
+ <DialogPrimitive.Portal>
316
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
317
+ <DialogPrimitive.Content
318
+ className={cn(
319
+ "creezio-search-palette fixed z-50 flex flex-col overflow-hidden bg-white shadow-2xl outline-none",
320
+ "inset-0 h-[100dvh] w-full rounded-none",
321
+ "md:inset-x-auto md:left-1/2 md:top-[8%] md:h-auto md:max-h-[min(680px,85vh)] md:w-[min(920px,calc(100vw-2rem))] md:-translate-x-1/2 md:rounded-xl md:border md:border-slate-200",
322
+ )}
323
+ onOpenAutoFocus={(e) => e.preventDefault()}
324
+ >
325
+ <DialogPrimitive.Title className="sr-only">Recherche globale</DialogPrimitive.Title>
326
+ <Command
327
+ shouldFilter={false}
328
+ className="flex h-full flex-col md:h-auto"
329
+ loop
330
+ >
331
+ {newTabMode ? (
332
+ <div className="flex shrink-0 items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-4 py-1.5 text-[11px] font-medium text-slate-500">
333
+ <Plus className="h-3 w-3" />
334
+ Le résultat s&apos;ouvrira dans un nouvel onglet
335
+ </div>
336
+ ) : null}
337
+ <div className="relative shrink-0">
338
+ <CommandInput
339
+ value={q}
340
+ onValueChange={setQ}
341
+ placeholder={config.placeholder || "Rechercher..."}
342
+ autoFocus
343
+ />
344
+ <button
345
+ type="button"
346
+ onClick={() => onOpenChange(false)}
347
+ className="absolute right-3 top-1/2 -translate-y-1/2 rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600 md:hidden"
348
+ aria-label="Fermer"
349
+ >
350
+ <X className="h-4 w-4" />
351
+ </button>
352
+ <kbd className="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] font-medium text-slate-400 md:inline">
353
+ esc
354
+ </kbd>
355
+ </div>
356
+
357
+ {!showHistory && (
358
+ <FilterChips
359
+ hits={hits}
360
+ active={filterIndex}
361
+ onChange={setFilterIndex}
362
+ labels={indexLabels}
363
+ />
364
+ )}
365
+
366
+ <div className="flex min-h-0 flex-1 md:grid md:grid-cols-[1fr_280px]">
367
+ <CommandList className="max-h-none flex-1 md:max-h-[min(480px,60vh)]">
368
+ {loading ? (
369
+ <div className="flex items-center gap-2 px-4 py-8 text-sm text-slate-500">
370
+ <Loader2 className="h-4 w-4 animate-spin" />
371
+ Recherche en cours…
372
+ </div>
373
+ ) : null}
374
+
375
+ {showHistory ? (
376
+ <>
377
+ {history.recent.length > 0 ? (
378
+ <CommandGroup heading="Récemment consultés">
379
+ {history.recent.map((hit) => (
380
+ <CommandItem
381
+ key={`recent-${hitKey(hit)}`}
382
+ value={`recent-${hitKey(hit)}`}
383
+ onSelect={() => navigateTo(hit)}
384
+ onMouseEnter={() => setSelectedKey(hitKey(hit))}
385
+ className="flex items-start justify-between gap-2"
386
+ >
387
+ <div className="min-w-0">
388
+ <p className="truncate font-medium">{hit.title}</p>
389
+ {hit.subtitle ? (
390
+ <p className="truncate text-xs text-slate-500">{hit.subtitle}</p>
391
+ ) : null}
392
+ </div>
393
+ <span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] uppercase text-slate-500">
394
+ {indexLabels[hit.index] || hit.index}
395
+ </span>
396
+ </CommandItem>
397
+ ))}
398
+ </CommandGroup>
399
+ ) : null}
400
+ {history.queries.length > 0 ? (
401
+ <>
402
+ {history.recent.length > 0 ? <CommandSeparator /> : null}
403
+ <CommandGroup heading="Recherches récentes">
404
+ {history.queries.map((query) => (
405
+ <CommandItem
406
+ key={`query-${query}`}
407
+ value={`query-${query}`}
408
+ onSelect={() => setQ(query)}
409
+ >
410
+ <History className="mr-2 h-4 w-4 text-slate-400" />
411
+ {query}
412
+ </CommandItem>
413
+ ))}
414
+ </CommandGroup>
415
+ </>
416
+ ) : null}
417
+ {!history.recent.length && !history.queries.length ? (
418
+ <div className="px-4 py-10 text-center text-sm text-slate-500">
419
+ <Clock className="mx-auto mb-2 h-6 w-6 opacity-40" />
420
+ Tapez au moins 2 caractères pour rechercher
421
+ </div>
422
+ ) : null}
423
+ </>
424
+ ) : null}
425
+
426
+ {!loading && !showHistory && !filteredHits.length ? (
427
+ <CommandEmpty>Aucun résultat pour "{q.trim()}"</CommandEmpty>
428
+ ) : null}
429
+
430
+ {!showHistory &&
431
+ grouped.map((group) => (
432
+ <CommandGroup key={group.index} heading={group.label}>
433
+ {group.hits.map((hit) => (
434
+ <CommandItem
435
+ key={hitKey(hit)}
436
+ value={hitKey(hit)}
437
+ onSelect={() => navigateTo(hit)}
438
+ onMouseEnter={() => setSelectedKey(hitKey(hit))}
439
+ className={cn(
440
+ "flex items-start justify-between gap-2",
441
+ selectedHit && hitKey(selectedHit) === hitKey(hit) && "bg-slate-100",
442
+ )}
443
+ >
444
+ <div className="min-w-0">
445
+ <p className="truncate font-medium">{hit.title}</p>
446
+ {hit.subtitle ? (
447
+ <p className="truncate text-xs text-slate-500">{hit.subtitle}</p>
448
+ ) : null}
449
+ </div>
450
+ <ArrowRight className="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-300" />
451
+ </CommandItem>
452
+ ))}
453
+ </CommandGroup>
454
+ ))}
455
+ </CommandList>
456
+
457
+ <aside className="hidden border-l border-slate-200 bg-slate-50/80 md:block">
458
+ <HitPreview hit={selectedHit} labels={indexLabels} />
459
+ </aside>
460
+ </div>
461
+
462
+ <footer className="hidden shrink-0 items-center gap-4 border-t border-slate-200 px-4 py-2 text-[11px] text-slate-400 md:flex">
463
+ <span className="flex items-center gap-1">
464
+ <kbd className="rounded border border-slate-200 bg-white px-1">↑</kbd>
465
+ <kbd className="rounded border border-slate-200 bg-white px-1">↓</kbd>
466
+ naviguer
467
+ </span>
468
+ <span className="flex items-center gap-1">
469
+ <kbd className="rounded border border-slate-200 bg-white px-1">↵</kbd>
470
+ ouvrir
471
+ </span>
472
+ <span className="flex items-center gap-1">
473
+ <kbd className="rounded border border-slate-200 bg-white px-1">esc</kbd>
474
+ fermer
475
+ </span>
476
+ </footer>
477
+ </Command>
478
+ </DialogPrimitive.Content>
479
+ </DialogPrimitive.Portal>
480
+ </DialogPrimitive.Root>
481
+ );
482
+ }
483
+
484
+ export function GlobalSearchProvider({ children }: { children: ReactNode }) {
485
+ const [open, setOpenState] = useState(false);
486
+ const [newTabMode, setNewTabMode] = useState(false);
487
+
488
+ const setOpen = useCallback((next: boolean) => {
489
+ setOpenState(next);
490
+ if (!next) setNewTabMode(false);
491
+ }, []);
492
+
493
+ const openForNewTab = useCallback(() => {
494
+ setNewTabMode(true);
495
+ setOpenState(true);
496
+ }, []);
497
+
498
+ useEffect(() => {
499
+ function onKeyDown(e: KeyboardEvent) {
500
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
501
+ e.preventDefault();
502
+ setOpenState((v) => {
503
+ if (v) setNewTabMode(false);
504
+ return !v;
505
+ });
506
+ }
507
+ }
508
+ document.addEventListener("keydown", onKeyDown);
509
+ return () => document.removeEventListener("keydown", onKeyDown);
510
+ }, []);
511
+
512
+ const value = useMemo(
513
+ () => ({ open, setOpen, openForNewTab }),
514
+ [open, setOpen, openForNewTab],
515
+ );
516
+
517
+ return (
518
+ <GlobalSearchContext.Provider value={value}>
519
+ {children}
520
+ <GlobalSearchModal open={open} onOpenChange={setOpen} newTabMode={newTabMode} />
521
+ </GlobalSearchContext.Provider>
522
+ );
523
+ }
524
+
525
+
526
+ configureGlobalSearchHost({
527
+ useGlobalSearch,
528
+ });
@@ -0,0 +1,74 @@
1
+ import { getGlobalSearchConfig } from "./global-search-config";
2
+ import type { GlobalSearchHit } from "./global-search-config";
3
+
4
+ let configuredStorageKey: string | null = null;
5
+ const DEFAULT_STORAGE_KEY = "creezio-global-search-v1";
6
+ const MAX_ITEMS = 10;
7
+
8
+ export type SearchHistory = {
9
+ queries: string[];
10
+ recent: GlobalSearchHit[];
11
+ };
12
+
13
+ export function configureSearchHistoryKey(key: string): void {
14
+ const next = key.trim();
15
+ if (next) configuredStorageKey = next;
16
+ }
17
+
18
+ function storageKey(): string {
19
+ if (configuredStorageKey) return configuredStorageKey;
20
+ try {
21
+ return getGlobalSearchConfig().storageKey || DEFAULT_STORAGE_KEY;
22
+ } catch {
23
+ return DEFAULT_STORAGE_KEY;
24
+ }
25
+ }
26
+
27
+ function read(): SearchHistory {
28
+ if (typeof window === "undefined") return { queries: [], recent: [] };
29
+ try {
30
+ const raw = localStorage.getItem(storageKey());
31
+ if (!raw) return { queries: [], recent: [] };
32
+ const parsed = JSON.parse(raw) as Partial<SearchHistory>;
33
+ return {
34
+ queries: Array.isArray(parsed.queries) ? parsed.queries.filter(Boolean) : [],
35
+ recent: Array.isArray(parsed.recent) ? parsed.recent : [],
36
+ };
37
+ } catch {
38
+ return { queries: [], recent: [] };
39
+ }
40
+ }
41
+
42
+ function write(history: SearchHistory) {
43
+ if (typeof window === "undefined") return;
44
+ try {
45
+ localStorage.setItem(storageKey(), JSON.stringify(history));
46
+ } catch {
47
+ /* quota */
48
+ }
49
+ }
50
+
51
+ export function loadSearchHistory(): SearchHistory {
52
+ return read();
53
+ }
54
+
55
+ export function pushSearchQuery(query: string) {
56
+ const q = query.trim();
57
+ if (q.length < 2) return;
58
+ const history = read();
59
+ history.queries = [q, ...history.queries.filter((x) => x !== q)].slice(0, MAX_ITEMS);
60
+ write(history);
61
+ }
62
+
63
+ export function pushRecentHit(hit: GlobalSearchHit) {
64
+ const history = read();
65
+ history.recent = [
66
+ hit,
67
+ ...history.recent.filter((x) => !(x.index === hit.index && x.id === hit.id)),
68
+ ].slice(0, MAX_ITEMS);
69
+ write(history);
70
+ }
71
+
72
+ export function clearSearchHistory() {
73
+ write({ queries: [], recent: [] });
74
+ }