@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,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'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'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
|
+
}
|