@underpostnet/underpost 2.97.0 → 2.97.5
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 +2 -2
- package/baremetal/commission-workflows.json +33 -3
- package/bin/deploy.js +1 -1
- package/cli.md +7 -2
- package/conf.js +3 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/package.json +1 -1
- package/packer/scripts/fuse-tar-root +3 -3
- package/scripts/disk-clean.sh +23 -23
- package/scripts/gpu-diag.sh +2 -2
- package/scripts/ip-info.sh +11 -11
- package/scripts/maas-upload-boot-resource.sh +1 -1
- package/scripts/nvim.sh +1 -1
- package/scripts/packer-setup.sh +13 -13
- package/scripts/rocky-setup.sh +2 -2
- package/scripts/rpmfusion-ffmpeg-setup.sh +4 -4
- package/scripts/ssl.sh +7 -7
- package/src/api/core/core.service.js +0 -5
- package/src/api/default/default.service.js +7 -5
- package/src/api/document/document.model.js +30 -1
- package/src/api/document/document.router.js +6 -0
- package/src/api/document/document.service.js +423 -51
- package/src/api/file/file.model.js +112 -4
- package/src/api/file/file.ref.json +42 -0
- package/src/api/file/file.service.js +380 -32
- package/src/api/user/user.model.js +38 -1
- package/src/api/user/user.router.js +96 -63
- package/src/api/user/user.service.js +81 -48
- package/src/cli/baremetal.js +689 -329
- package/src/cli/cluster.js +50 -52
- package/src/cli/db.js +424 -166
- package/src/cli/deploy.js +1 -1
- package/src/cli/index.js +12 -1
- package/src/cli/lxd.js +3 -3
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +2 -1
- package/src/cli/ssh.js +10 -10
- package/src/client/components/core/Account.js +327 -36
- package/src/client/components/core/AgGrid.js +3 -0
- package/src/client/components/core/Auth.js +9 -3
- package/src/client/components/core/Chat.js +2 -2
- package/src/client/components/core/Content.js +159 -78
- package/src/client/components/core/Css.js +16 -2
- package/src/client/components/core/CssCore.js +16 -12
- package/src/client/components/core/FileExplorer.js +115 -8
- package/src/client/components/core/Input.js +204 -11
- package/src/client/components/core/LogIn.js +42 -20
- package/src/client/components/core/Modal.js +257 -177
- package/src/client/components/core/Panel.js +324 -27
- package/src/client/components/core/PanelForm.js +280 -73
- package/src/client/components/core/PublicProfile.js +888 -0
- package/src/client/components/core/Router.js +117 -15
- package/src/client/components/core/SearchBox.js +1117 -0
- package/src/client/components/core/SignUp.js +26 -7
- package/src/client/components/core/SocketIo.js +6 -3
- package/src/client/components/core/Translate.js +98 -0
- package/src/client/components/core/Validator.js +15 -0
- package/src/client/components/core/windowGetDimensions.js +6 -6
- package/src/client/components/default/MenuDefault.js +59 -12
- package/src/client/components/default/RoutesDefault.js +1 -0
- package/src/client/services/core/core.service.js +163 -1
- package/src/client/services/default/default.management.js +451 -64
- package/src/client/services/default/default.service.js +13 -6
- package/src/client/services/document/document.service.js +23 -0
- package/src/client/services/file/file.service.js +43 -16
- package/src/client/services/user/user.service.js +13 -9
- package/src/db/DataBaseProvider.js +1 -1
- package/src/db/mongo/MongooseDB.js +1 -1
- package/src/index.js +1 -1
- package/src/mailer/MailerProvider.js +4 -4
- package/src/runtime/express/Express.js +2 -1
- package/src/runtime/lampp/Lampp.js +2 -2
- package/src/server/auth.js +3 -6
- package/src/server/data-query.js +449 -0
- package/src/server/dns.js +4 -4
- package/src/server/object-layer.js +0 -3
- package/src/ws/IoInterface.js +2 -2
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable search component with extensible plugin architecture.
|
|
3
|
+
* Provides typeahead search functionality with support for multiple search providers,
|
|
4
|
+
* custom rendering, keyboard navigation, and theme-aware styling.
|
|
5
|
+
* @module src/client/components/core/SearchBox.js
|
|
6
|
+
* @namespace SearchBoxClient
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loggerFactory } from './Logger.js';
|
|
10
|
+
import { s, getAllChildNodes, htmls } from './VanillaJs.js';
|
|
11
|
+
import { Translate } from './Translate.js';
|
|
12
|
+
import { darkTheme, ThemeEvents, subThemeManager, lightenHex, darkenHex } from './Css.js';
|
|
13
|
+
|
|
14
|
+
const logger = loggerFactory(import.meta);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* SearchBox singleton object providing extensible search functionality.
|
|
18
|
+
* Supports default menu/route search and pluggable search providers with
|
|
19
|
+
* custom rendering, click handlers, and result merging.
|
|
20
|
+
* @memberof SearchBoxClient
|
|
21
|
+
*/
|
|
22
|
+
const SearchBox = {
|
|
23
|
+
/**
|
|
24
|
+
* Internal data storage for search state and handlers.
|
|
25
|
+
* @type {object}
|
|
26
|
+
* @memberof SearchBoxClient.SearchBox
|
|
27
|
+
*/
|
|
28
|
+
Data: {},
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Registry of registered search provider plugins.
|
|
32
|
+
* Each provider implements the search provider interface:
|
|
33
|
+
* - id: Unique identifier string
|
|
34
|
+
* - search: async (query, context) => Promise<Array<result>>
|
|
35
|
+
* - renderResult: (result, index, context) => string (HTML)
|
|
36
|
+
* - onClick: (result, context) => void
|
|
37
|
+
* - priority: number (lower number = higher priority)
|
|
38
|
+
* @type {Array<object>}
|
|
39
|
+
* @memberof SearchBoxClient.SearchBox
|
|
40
|
+
*/
|
|
41
|
+
providers: [],
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Recent search results manager with localStorage persistence.
|
|
45
|
+
* Tracks clicked results from all providers (routes and custom).
|
|
46
|
+
* Maintains order of most-recent-first results across sessions.
|
|
47
|
+
* @type {object}
|
|
48
|
+
* @memberof SearchBoxClient.SearchBox
|
|
49
|
+
*/
|
|
50
|
+
RecentResults: {
|
|
51
|
+
/**
|
|
52
|
+
* Storage key for localStorage persistence
|
|
53
|
+
* @type {string}
|
|
54
|
+
*/
|
|
55
|
+
storageKey: 'searchbox_recent_results',
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Maximum number of recent results to keep in history
|
|
59
|
+
* @type {number}
|
|
60
|
+
*/
|
|
61
|
+
maxResults: 20,
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get all cached recent results from localStorage
|
|
65
|
+
* @returns {Array<object>} Array of recent result objects
|
|
66
|
+
*/
|
|
67
|
+
getAll: function () {
|
|
68
|
+
try {
|
|
69
|
+
const stored = localStorage.getItem(this.storageKey);
|
|
70
|
+
return stored ? JSON.parse(stored) : [];
|
|
71
|
+
} catch (error) {
|
|
72
|
+
logger.warn('Error reading search history from localStorage:', error);
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Save recent results to localStorage
|
|
79
|
+
* @param {Array<object>} results - Array of results to save
|
|
80
|
+
*/
|
|
81
|
+
saveAll: function (results) {
|
|
82
|
+
try {
|
|
83
|
+
localStorage.setItem(this.storageKey, JSON.stringify(results.slice(0, this.maxResults)));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
logger.warn('Error saving search history to localStorage:', error);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Add a result to recent history (moves to front if duplicate)
|
|
91
|
+
* Removes duplicates and maintains max size limit.
|
|
92
|
+
* Only stores serializable data (excludes DOM elements).
|
|
93
|
+
* @param {object} result - Result object to add (must have id and providerId/routerId)
|
|
94
|
+
*/
|
|
95
|
+
add: function (result) {
|
|
96
|
+
if (!result || (!result.id && !result.routerId)) {
|
|
97
|
+
logger.warn('SearchBox.RecentResults.add: Invalid result, missing id or routerId');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Create a clean copy excluding DOM elements (fontAwesomeIcon, imgElement)
|
|
102
|
+
const cleanResult = {
|
|
103
|
+
id: result.id,
|
|
104
|
+
routerId: result.routerId,
|
|
105
|
+
type: result.type,
|
|
106
|
+
providerId: result.providerId,
|
|
107
|
+
title: result.title,
|
|
108
|
+
subtitle: result.subtitle,
|
|
109
|
+
tags: result.tags,
|
|
110
|
+
createdAt: result.createdAt,
|
|
111
|
+
data: result.data,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const recent = this.getAll();
|
|
115
|
+
|
|
116
|
+
// Remove duplicate if it exists (based on id and providerId/routerId)
|
|
117
|
+
const filteredRecent = recent.filter((r) => {
|
|
118
|
+
if (cleanResult.providerId && r.providerId) {
|
|
119
|
+
return !(r.id === cleanResult.id && r.providerId === cleanResult.providerId);
|
|
120
|
+
} else if (cleanResult.routerId && r.routerId) {
|
|
121
|
+
return r.routerId !== cleanResult.routerId;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Add new result to front
|
|
127
|
+
filteredRecent.unshift(cleanResult);
|
|
128
|
+
|
|
129
|
+
// Save to localStorage
|
|
130
|
+
this.saveAll(filteredRecent);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Clear all recent results from localStorage
|
|
135
|
+
*/
|
|
136
|
+
clear: function () {
|
|
137
|
+
try {
|
|
138
|
+
localStorage.removeItem(this.storageKey);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.warn('Error clearing search history:', error);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Remove a single result from recent history by ID and provider
|
|
146
|
+
* @param {string} resultId - Result ID to remove
|
|
147
|
+
* @param {string} [providerId] - Provider ID of the result (optional, for routes use null)
|
|
148
|
+
*/
|
|
149
|
+
remove: function (resultId, providerId) {
|
|
150
|
+
const recent = this.getAll();
|
|
151
|
+
const filtered = recent.filter((r) => {
|
|
152
|
+
// Match by ID and providerId (or routerId for routes)
|
|
153
|
+
if (providerId) {
|
|
154
|
+
return !(r.id === resultId && r.providerId === providerId);
|
|
155
|
+
} else {
|
|
156
|
+
// For routes (providerId is null), match by routerId instead
|
|
157
|
+
return !(r.routerId === resultId);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
this.saveAll(filtered);
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Registers a search provider plugin for extensible search functionality.
|
|
166
|
+
* Replaces any existing provider with the same ID.
|
|
167
|
+
* @memberof SearchBoxClient.SearchBox
|
|
168
|
+
* @param {object} provider - The search provider object to register.
|
|
169
|
+
* @param {string} provider.id - Unique identifier for the provider.
|
|
170
|
+
* @param {Function} provider.search - Async function: (query, context) => Promise<Array<result>>.
|
|
171
|
+
* @param {Function} [provider.renderResult] - Custom renderer: (result, index, context) => HTML string.
|
|
172
|
+
* @param {Function} [provider.onClick] - Click handler: (result, context) => void.
|
|
173
|
+
* @param {number} [provider.priority=50] - Priority for result ordering (lower = higher priority).
|
|
174
|
+
* @returns {void}
|
|
175
|
+
*/
|
|
176
|
+
registerProvider: function (provider) {
|
|
177
|
+
if (!provider.id || !provider.search) {
|
|
178
|
+
logger.error('Invalid provider. Must have id and search function');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Remove existing provider with same id
|
|
183
|
+
this.providers = this.providers.filter((p) => p.id !== provider.id);
|
|
184
|
+
|
|
185
|
+
// Add new provider
|
|
186
|
+
this.providers.push({
|
|
187
|
+
id: provider.id,
|
|
188
|
+
search: provider.search,
|
|
189
|
+
renderResult: provider.renderResult || ((result) => this.defaultRenderResult(result)),
|
|
190
|
+
onClick: provider.onClick || (() => {}),
|
|
191
|
+
priority: provider.priority || 50, // Lower number = higher priority in results
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
logger.info(`Registered search provider: ${provider.id}`);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Unregisters a search provider by its ID.
|
|
199
|
+
* @memberof SearchBoxClient.SearchBox
|
|
200
|
+
* @param {string} providerId - The ID of the provider to unregister.
|
|
201
|
+
* @returns {void}
|
|
202
|
+
*/
|
|
203
|
+
unregisterProvider: function (providerId) {
|
|
204
|
+
this.providers = this.providers.filter((p) => p.id !== providerId);
|
|
205
|
+
logger.info(`Unregistered search provider: ${providerId}`);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Default result renderer with support for tags and badges.
|
|
210
|
+
* Used when a provider doesn't supply a custom renderResult function.
|
|
211
|
+
* @memberof SearchBoxClient.SearchBox
|
|
212
|
+
* @param {object} result - The search result object to render.
|
|
213
|
+
* @param {string} result.id - Result identifier.
|
|
214
|
+
* @param {string} [result.icon] - HTML for icon display.
|
|
215
|
+
* @param {string} [result.title] - Result title text.
|
|
216
|
+
* @param {string} [result.subtitle] - Result subtitle text.
|
|
217
|
+
* @param {Array<string>} [result.tags] - Array of tag strings.
|
|
218
|
+
* @param {string} result.type - Result type identifier.
|
|
219
|
+
* @param {string} result.providerId - Provider ID that generated this result.
|
|
220
|
+
* @returns {string} HTML string for the search result.
|
|
221
|
+
*/
|
|
222
|
+
defaultRenderResult: function (result) {
|
|
223
|
+
const icon = result.icon || '<i class="fas fa-file"></i>';
|
|
224
|
+
const title = result.title || result.id || 'Untitled';
|
|
225
|
+
const subtitle = result.subtitle || '';
|
|
226
|
+
const tags = result.tags || [];
|
|
227
|
+
|
|
228
|
+
// Render tags if available
|
|
229
|
+
const tagsHtml =
|
|
230
|
+
tags.length > 0
|
|
231
|
+
? `<div class="search-result-tags">
|
|
232
|
+
${tags.map((tag) => `<span class="search-result-tag">${tag}</span>`).join('')}
|
|
233
|
+
</div>`
|
|
234
|
+
: '';
|
|
235
|
+
|
|
236
|
+
return html`
|
|
237
|
+
<div
|
|
238
|
+
class="search-result-item"
|
|
239
|
+
data-result-id="${result.id}"
|
|
240
|
+
data-result-type="${result.type}"
|
|
241
|
+
data-provider-id="${result.providerId}"
|
|
242
|
+
>
|
|
243
|
+
<div class="search-result-icon">${icon}</div>
|
|
244
|
+
<div class="search-result-content">
|
|
245
|
+
<div class="search-result-title">${title}</div>
|
|
246
|
+
${subtitle ? `<div class="search-result-subtitle">${subtitle}</div>` : ''} ${tagsHtml}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
`;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Navigates through search results using keyboard arrow keys.
|
|
254
|
+
* Optimized for performance with direct DOM manipulation and efficient scrolling.
|
|
255
|
+
* Supports wrap-around navigation (top to bottom and vice versa).
|
|
256
|
+
* @memberof SearchBoxClient.SearchBox
|
|
257
|
+
* @param {string} direction - Navigation direction: 'up' or 'down'.
|
|
258
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
259
|
+
* @param {number} currentIndex - Current active result index (0-based).
|
|
260
|
+
* @param {number} totalItems - Total number of result items.
|
|
261
|
+
* @returns {number} New active index after navigation.
|
|
262
|
+
*/
|
|
263
|
+
navigateResults: function (direction, containerId, currentIndex, totalItems) {
|
|
264
|
+
if (!containerId || totalItems === 0) return currentIndex;
|
|
265
|
+
|
|
266
|
+
const container = s(`#${containerId}`) || s(`.${containerId}`);
|
|
267
|
+
const allItems = container ? container.querySelectorAll('.search-result-item') : [];
|
|
268
|
+
|
|
269
|
+
if (!allItems || allItems.length === 0) return currentIndex;
|
|
270
|
+
|
|
271
|
+
// Remove active class from current item (efficient DOM manipulation)
|
|
272
|
+
if (allItems[currentIndex]) {
|
|
273
|
+
allItems[currentIndex].classList.remove('active-search-result');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Calculate new index with wrap-around
|
|
277
|
+
let newIndex = currentIndex;
|
|
278
|
+
if (direction === 'up') {
|
|
279
|
+
newIndex = currentIndex > 0 ? currentIndex - 1 : allItems.length - 1;
|
|
280
|
+
} else if (direction === 'down') {
|
|
281
|
+
newIndex = currentIndex < allItems.length - 1 ? currentIndex + 1 : 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Add active class to new item and ensure visibility
|
|
285
|
+
if (allItems[newIndex]) {
|
|
286
|
+
allItems[newIndex].classList.add('active-search-result');
|
|
287
|
+
// Use optimized scroll method (no animation, instant positioning)
|
|
288
|
+
this.scrollIntoViewIfNeeded(allItems[newIndex], container);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return newIndex;
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Searches through default application routes for matches.
|
|
296
|
+
* Backward compatible with Modal.js search functionality.
|
|
297
|
+
* Matches route IDs and translated route names against the query string.
|
|
298
|
+
* @memberof SearchBoxClient.SearchBox
|
|
299
|
+
* @param {string} query - The search query string.
|
|
300
|
+
* @param {object} context - Search context object.
|
|
301
|
+
* @param {object} [context.RouterInstance] - Router instance containing routes.
|
|
302
|
+
* @param {object} [context.options] - Additional search options.
|
|
303
|
+
* @param {string} [context.options.searchCustomImgClass] - Custom image class to search for.
|
|
304
|
+
* @returns {Array<object>} Array of route search results.
|
|
305
|
+
*/
|
|
306
|
+
searchRoutes: function (query, context) {
|
|
307
|
+
const results = [];
|
|
308
|
+
const { RouterInstance, options = {} } = context;
|
|
309
|
+
|
|
310
|
+
if (!RouterInstance) return results;
|
|
311
|
+
|
|
312
|
+
const routerInstance = RouterInstance.Routes();
|
|
313
|
+
for (const _routerId of Object.keys(routerInstance)) {
|
|
314
|
+
const routerId = _routerId.slice(1);
|
|
315
|
+
if (routerId) {
|
|
316
|
+
if (
|
|
317
|
+
s(`.main-btn-${routerId}`) &&
|
|
318
|
+
(routerId.toLowerCase().match(query.toLowerCase()) ||
|
|
319
|
+
(Translate.Data[routerId] &&
|
|
320
|
+
Object.keys(Translate.Data[routerId]).filter((keyLang) =>
|
|
321
|
+
Translate.Data[routerId][keyLang].toLowerCase().match(query.toLowerCase()),
|
|
322
|
+
).length > 0))
|
|
323
|
+
) {
|
|
324
|
+
const fontAwesomeIcon = getAllChildNodes(s(`.main-btn-${routerId}`)).find((e) => {
|
|
325
|
+
return e.classList && Array.from(e.classList).find((e) => e.match('fa-') && !e.match('fa-grip-vertical'));
|
|
326
|
+
});
|
|
327
|
+
const imgElement = getAllChildNodes(s(`.main-btn-${routerId}`)).find((e) => {
|
|
328
|
+
return (
|
|
329
|
+
e.classList &&
|
|
330
|
+
Array.from(e.classList).find((e) =>
|
|
331
|
+
options.searchCustomImgClass ? e.match(options.searchCustomImgClass) : e.match('img-btn-square-menu'),
|
|
332
|
+
)
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
if (imgElement || fontAwesomeIcon) {
|
|
336
|
+
results.push({
|
|
337
|
+
id: routerId,
|
|
338
|
+
routerId,
|
|
339
|
+
fontAwesomeIcon,
|
|
340
|
+
imgElement,
|
|
341
|
+
type: 'route',
|
|
342
|
+
providerId: 'default-routes',
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return results;
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Executes search across all registered providers and default routes.
|
|
353
|
+
* Combines results from multiple sources and sorts by priority.
|
|
354
|
+
* @memberof SearchBoxClient.SearchBox
|
|
355
|
+
* @param {string} query - The search query string.
|
|
356
|
+
* @param {object} [context={}] - Search context object passed to all providers.
|
|
357
|
+
* @returns {Promise<Array<object>>} Promise resolving to combined, priority-sorted results array.
|
|
358
|
+
*/
|
|
359
|
+
search: async function (query, context = {}) {
|
|
360
|
+
const allResults = [];
|
|
361
|
+
|
|
362
|
+
// Always include default route search (backward compatible)
|
|
363
|
+
const routeResults = this.searchRoutes(query, context);
|
|
364
|
+
allResults.push(...routeResults);
|
|
365
|
+
|
|
366
|
+
// Execute all registered providers
|
|
367
|
+
const providerPromises = this.providers.map(async (provider) => {
|
|
368
|
+
try {
|
|
369
|
+
const results = await provider.search(query, context);
|
|
370
|
+
return results.map((result) => ({
|
|
371
|
+
...result,
|
|
372
|
+
providerId: provider.id,
|
|
373
|
+
priority: provider.priority,
|
|
374
|
+
}));
|
|
375
|
+
} catch (error) {
|
|
376
|
+
logger.error(`Error in provider ${provider.id}:`, error);
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const providerResults = await Promise.all(providerPromises);
|
|
382
|
+
providerResults.forEach((results) => {
|
|
383
|
+
allResults.push(...results);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Sort by priority
|
|
387
|
+
allResults.sort((a, b) => (a.priority || 50) - (b.priority || 50));
|
|
388
|
+
|
|
389
|
+
return allResults;
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Renders search results into a container element.
|
|
394
|
+
* Delegates rendering to provider-specific renderers or default route renderer.
|
|
395
|
+
* Automatically attaches click handlers and calls provider post-render hooks.
|
|
396
|
+
* @memberof SearchBoxClient.SearchBox
|
|
397
|
+
* @param {Array<object>} results - Array of search results to render.
|
|
398
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
399
|
+
* @param {object} [context={}] - Render context passed to renderers and handlers.
|
|
400
|
+
* @returns {void}
|
|
401
|
+
*/
|
|
402
|
+
renderResults: function (results, containerId, context = {}) {
|
|
403
|
+
const container = s(`#${containerId}`) || s(`.${containerId}`);
|
|
404
|
+
if (!container) {
|
|
405
|
+
logger.warn(`Container ${containerId} not found`);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!results || results.length === 0) {
|
|
410
|
+
container.innerHTML = '';
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Check if this is rendering recently clicked items (not search results)
|
|
415
|
+
// context.isRecentHistory is set when rendering from history, not from search query
|
|
416
|
+
const isRecentHistory = context.isRecentHistory === true;
|
|
417
|
+
|
|
418
|
+
let htmlContent = '';
|
|
419
|
+
results.forEach((result, index) => {
|
|
420
|
+
const provider = this.providers.find((p) => p.id === result.providerId);
|
|
421
|
+
|
|
422
|
+
let resultHtml = '';
|
|
423
|
+
if (result.type === 'route' || !provider) {
|
|
424
|
+
// Default route rendering (backward compatible)
|
|
425
|
+
resultHtml = this.renderRouteResult(result, index, context);
|
|
426
|
+
} else {
|
|
427
|
+
// Custom provider rendering
|
|
428
|
+
resultHtml = provider.renderResult(result, index, context);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Only add delete button for recently clicked items (not search results)
|
|
432
|
+
if (isRecentHistory) {
|
|
433
|
+
// Wrapper with relative position for absolute delete button
|
|
434
|
+
htmlContent += `
|
|
435
|
+
<div class="search-result-wrapper search-result-history-item" data-result-id="${result.id || result.routerId}" data-provider-id="${result.providerId || 'default-routes'}">
|
|
436
|
+
${resultHtml}
|
|
437
|
+
<button
|
|
438
|
+
class="search-result-delete-btn"
|
|
439
|
+
data-result-id="${result.id || result.routerId}"
|
|
440
|
+
data-provider-id="${result.providerId || 'default-routes'}"
|
|
441
|
+
title="Remove from history"
|
|
442
|
+
aria-label="Remove from history"
|
|
443
|
+
>
|
|
444
|
+
<i class="fas fa-trash-alt"></i>
|
|
445
|
+
</button>
|
|
446
|
+
</div>
|
|
447
|
+
`;
|
|
448
|
+
} else {
|
|
449
|
+
// Search results: no delete button, no wrapper overhead
|
|
450
|
+
htmlContent += resultHtml;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
container.innerHTML = htmlContent;
|
|
455
|
+
|
|
456
|
+
// Attach click handlers
|
|
457
|
+
this.attachClickHandlers(results, containerId, context);
|
|
458
|
+
|
|
459
|
+
// Only attach delete handlers for recently clicked items
|
|
460
|
+
if (isRecentHistory) {
|
|
461
|
+
this.attachDeleteHandlers(container, results, containerId, context);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Call post-render callbacks from providers
|
|
465
|
+
results.forEach((result) => {
|
|
466
|
+
const provider = this.providers.find((p) => p.id === result.providerId);
|
|
467
|
+
if (provider && provider.attachTagHandlers) {
|
|
468
|
+
provider.attachTagHandlers();
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
},
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Attaches delete event handlers to result delete buttons within a specific container.
|
|
475
|
+
* Removes only the clicked result from history with smooth animation feedback.
|
|
476
|
+
* Only affects delete buttons within the specified container.
|
|
477
|
+
* @memberof SearchBoxClient.SearchBox
|
|
478
|
+
* @param {HTMLElement} container - The container element to search within.
|
|
479
|
+
* @param {Array<object>} results - Array of search results.
|
|
480
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
481
|
+
* @param {object} [context={}] - Context object.
|
|
482
|
+
* @returns {void}
|
|
483
|
+
*/
|
|
484
|
+
attachDeleteHandlers: function (container, results, containerId, context = {}) {
|
|
485
|
+
// Only select delete buttons within this specific container
|
|
486
|
+
const deleteButtons = container.querySelectorAll('.search-result-delete-btn');
|
|
487
|
+
deleteButtons.forEach((btn) => {
|
|
488
|
+
btn.addEventListener('click', (e) => {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
e.stopPropagation();
|
|
491
|
+
|
|
492
|
+
const resultId = btn.getAttribute('data-result-id');
|
|
493
|
+
const providerId = btn.getAttribute('data-provider-id');
|
|
494
|
+
|
|
495
|
+
// Animate removal
|
|
496
|
+
const wrapper = btn.closest('.search-result-history-item');
|
|
497
|
+
if (wrapper) {
|
|
498
|
+
wrapper.classList.add('search-result-removing');
|
|
499
|
+
setTimeout(() => {
|
|
500
|
+
// Remove only this specific result from history storage
|
|
501
|
+
if (providerId === 'default-routes') {
|
|
502
|
+
this.RecentResults.remove(resultId, null);
|
|
503
|
+
} else {
|
|
504
|
+
this.RecentResults.remove(resultId, providerId);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Filter out only the deleted result from the array
|
|
508
|
+
const remaining = results.filter((r) => {
|
|
509
|
+
if (providerId === 'default-routes') {
|
|
510
|
+
// For routes, match by routerId
|
|
511
|
+
return r.routerId !== resultId;
|
|
512
|
+
} else {
|
|
513
|
+
// For providers, match by id and providerId
|
|
514
|
+
return !(r.id === resultId && r.providerId === providerId);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Re-render with remaining results (context.isRecentHistory should stay true)
|
|
519
|
+
if (remaining.length > 0) {
|
|
520
|
+
this.renderResults(remaining, containerId, context);
|
|
521
|
+
} else {
|
|
522
|
+
// If no results left, clear container and hide clear-all button
|
|
523
|
+
container.innerHTML = '';
|
|
524
|
+
const clearAllBtn = document.querySelector('.btn-search-history-clear-all');
|
|
525
|
+
if (clearAllBtn) {
|
|
526
|
+
clearAllBtn.style.display = 'none';
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}, 200);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Renders a default route search result.
|
|
537
|
+
* Backward compatible with Modal.js search functionality.
|
|
538
|
+
* Displays route icon and translated route name.
|
|
539
|
+
* @memberof SearchBoxClient.SearchBox
|
|
540
|
+
* @param {object} result - The route result object to render.
|
|
541
|
+
* @param {string} result.routerId - Route identifier.
|
|
542
|
+
* @param {HTMLElement} [result.fontAwesomeIcon] - FontAwesome icon element.
|
|
543
|
+
* @param {HTMLElement} [result.imgElement] - Image icon element.
|
|
544
|
+
* @param {number} index - The index of this result in the results array.
|
|
545
|
+
* @param {object} [context={}] - Render context object.
|
|
546
|
+
* @param {object} [context.options] - Additional rendering options.
|
|
547
|
+
* @returns {string} HTML string for the route search result.
|
|
548
|
+
*/
|
|
549
|
+
renderRouteResult: function (result, index, context = {}) {
|
|
550
|
+
const { options = {} } = context;
|
|
551
|
+
const routerId = result.routerId;
|
|
552
|
+
const fontAwesomeIcon = result.fontAwesomeIcon;
|
|
553
|
+
const imgElement = result.imgElement;
|
|
554
|
+
|
|
555
|
+
let iconHtml = '';
|
|
556
|
+
|
|
557
|
+
// For route results from history, reconstruct icons from DOM
|
|
558
|
+
if (!fontAwesomeIcon && !imgElement && routerId) {
|
|
559
|
+
const routeBtn = s(`.main-btn-${routerId}`);
|
|
560
|
+
if (routeBtn) {
|
|
561
|
+
const icon = getAllChildNodes(routeBtn).find((e) => {
|
|
562
|
+
return e.classList && Array.from(e.classList).find((e) => e.match('fa-') && !e.match('fa-grip-vertical'));
|
|
563
|
+
});
|
|
564
|
+
const img = getAllChildNodes(routeBtn).find((e) => {
|
|
565
|
+
return (
|
|
566
|
+
e.classList &&
|
|
567
|
+
Array.from(e.classList).find((e) =>
|
|
568
|
+
options.searchCustomImgClass ? e.match(options.searchCustomImgClass) : e.match('img-btn-square-menu'),
|
|
569
|
+
)
|
|
570
|
+
);
|
|
571
|
+
});
|
|
572
|
+
if (img) {
|
|
573
|
+
iconHtml = img.outerHTML;
|
|
574
|
+
} else if (icon) {
|
|
575
|
+
iconHtml = icon.outerHTML;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} else {
|
|
579
|
+
// For fresh search results, use provided DOM elements
|
|
580
|
+
if (imgElement) {
|
|
581
|
+
iconHtml = imgElement.outerHTML;
|
|
582
|
+
} else if (fontAwesomeIcon) {
|
|
583
|
+
iconHtml = fontAwesomeIcon.outerHTML;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const translatedText = Translate.Render(routerId);
|
|
588
|
+
|
|
589
|
+
return html`
|
|
590
|
+
<div
|
|
591
|
+
class="search-result-item search-result-route"
|
|
592
|
+
data-result-id="${routerId}"
|
|
593
|
+
data-result-type="route"
|
|
594
|
+
data-result-index="${index}"
|
|
595
|
+
data-provider-id="default-routes"
|
|
596
|
+
>
|
|
597
|
+
<div class="search-result-icon">${iconHtml}</div>
|
|
598
|
+
<div class="search-result-content">
|
|
599
|
+
<div class="search-result-title">${translatedText}</div>
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
`;
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Attaches click event handlers to all rendered search results.
|
|
607
|
+
* Routes trigger menu button clicks; custom providers call their onClick handlers.
|
|
608
|
+
* @memberof SearchBoxClient.SearchBox
|
|
609
|
+
* @param {Array<object>} results - Array of search results.
|
|
610
|
+
* @param {string} containerId - Results container element ID or class name.
|
|
611
|
+
* @param {object} [context={}] - Context object with callbacks.
|
|
612
|
+
* @param {Function} [context.onResultClick] - Callback invoked after any result is clicked.
|
|
613
|
+
* @returns {void}
|
|
614
|
+
*/
|
|
615
|
+
attachClickHandlers: function (results, containerId, context = {}) {
|
|
616
|
+
results.forEach((result, index) => {
|
|
617
|
+
const element = s(`[data-result-index="${index}"]`);
|
|
618
|
+
if (!element) return;
|
|
619
|
+
|
|
620
|
+
element.onclick = (e) => {
|
|
621
|
+
e.preventDefault();
|
|
622
|
+
e.stopPropagation();
|
|
623
|
+
|
|
624
|
+
// Track result in persistent history for all result types
|
|
625
|
+
this.RecentResults.add(result);
|
|
626
|
+
|
|
627
|
+
const provider = this.providers.find((p) => p.id === result.providerId);
|
|
628
|
+
|
|
629
|
+
if (result.type === 'route') {
|
|
630
|
+
// Default route behavior - click the menu button
|
|
631
|
+
const btnSelector = `.main-btn-${result.routerId}`;
|
|
632
|
+
if (s(btnSelector)) {
|
|
633
|
+
s(btnSelector).click();
|
|
634
|
+
}
|
|
635
|
+
} else if (provider && provider.onClick) {
|
|
636
|
+
// Custom provider click handler
|
|
637
|
+
provider.onClick(result, context);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Dismiss search box if callback provided
|
|
641
|
+
if (context.onResultClick) {
|
|
642
|
+
context.onResultClick(result);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Scrolls an element into view within a scrollable container if needed.
|
|
650
|
+
* Performance-critical for keyboard navigation - uses direct scrollTop manipulation
|
|
651
|
+
* instead of smooth scrolling to reduce overhead and ensure instant visibility.
|
|
652
|
+
*
|
|
653
|
+
* ROBUST IMPLEMENTATION:
|
|
654
|
+
* - Auto-detects the actual scrollable parent container
|
|
655
|
+
* - Uses getBoundingClientRect() for accurate viewport-aware positioning
|
|
656
|
+
* - Handles complex DOM structures (modals, positioned elements, transforms)
|
|
657
|
+
* - Includes fallback to native scrollIntoView() if custom logic fails
|
|
658
|
+
*
|
|
659
|
+
* Algorithm:
|
|
660
|
+
* 1. Find actual scrollable container (may be parent of passed container)
|
|
661
|
+
* 2. Calculate element position relative to container's visible area
|
|
662
|
+
* 3. Determine scroll adjustment needed (up, down, or none)
|
|
663
|
+
* 4. Apply scroll adjustment
|
|
664
|
+
* 5. Verify visibility and use native scrollIntoView as fallback if needed
|
|
665
|
+
*
|
|
666
|
+
* @memberof SearchBoxClient.SearchBox
|
|
667
|
+
* @param {HTMLElement} element - The element to scroll into view.
|
|
668
|
+
* @param {HTMLElement} container - The scrollable container (or parent of scrollable).
|
|
669
|
+
* @returns {void}
|
|
670
|
+
*/
|
|
671
|
+
scrollIntoViewIfNeeded: function (element, container) {
|
|
672
|
+
if (!element || !container) return;
|
|
673
|
+
|
|
674
|
+
// CRITICAL FIX: Find the actual scrollable container
|
|
675
|
+
// The passed container might not be scrollable; we need to find the parent that is
|
|
676
|
+
let scrollableContainer = container;
|
|
677
|
+
|
|
678
|
+
// Check if current container is scrollable
|
|
679
|
+
const isScrollable = (el) => {
|
|
680
|
+
if (!el) return false;
|
|
681
|
+
const hasScroll = el.scrollHeight > el.clientHeight;
|
|
682
|
+
const overflowY = window.getComputedStyle(el).overflowY;
|
|
683
|
+
return hasScroll && (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay');
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// If container is not scrollable, traverse up to find scrollable parent
|
|
687
|
+
if (!isScrollable(container)) {
|
|
688
|
+
let parent = container.parentElement;
|
|
689
|
+
while (parent && parent !== document.body) {
|
|
690
|
+
if (isScrollable(parent)) {
|
|
691
|
+
scrollableContainer = parent;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
parent = parent.parentElement;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ROBUST POSITION CALCULATION
|
|
699
|
+
// Get element's position relative to scrollable container using getBoundingClientRect
|
|
700
|
+
// This handles all edge cases including transformed elements, scrolled parents, etc.
|
|
701
|
+
const elementRect = element.getBoundingClientRect();
|
|
702
|
+
const containerRect = scrollableContainer.getBoundingClientRect();
|
|
703
|
+
|
|
704
|
+
// Calculate element position relative to container's visible area
|
|
705
|
+
const elementTopRelative = elementRect.top - containerRect.top;
|
|
706
|
+
const elementBottomRelative = elementRect.bottom - containerRect.top;
|
|
707
|
+
const containerVisibleHeight = scrollableContainer.clientHeight;
|
|
708
|
+
|
|
709
|
+
// Add padding to avoid elements being exactly at edges (better UX)
|
|
710
|
+
const padding = 10;
|
|
711
|
+
|
|
712
|
+
// Determine scroll adjustment needed
|
|
713
|
+
let scrollAdjustment = 0;
|
|
714
|
+
|
|
715
|
+
// Element is ABOVE visible area
|
|
716
|
+
if (elementTopRelative < padding) {
|
|
717
|
+
// Need to scroll up
|
|
718
|
+
scrollAdjustment = elementTopRelative - padding;
|
|
719
|
+
}
|
|
720
|
+
// Element is BELOW visible area
|
|
721
|
+
else if (elementBottomRelative > containerVisibleHeight - padding) {
|
|
722
|
+
// Need to scroll down
|
|
723
|
+
scrollAdjustment = elementBottomRelative - containerVisibleHeight + padding;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Apply scroll adjustment if needed
|
|
727
|
+
if (scrollAdjustment !== 0) {
|
|
728
|
+
scrollableContainer.scrollTop += scrollAdjustment;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// FALLBACK: If custom scroll didn't work, use native scrollIntoView
|
|
732
|
+
// This ensures visibility even if our calculation fails
|
|
733
|
+
setTimeout(() => {
|
|
734
|
+
const rectCheck = element.getBoundingClientRect();
|
|
735
|
+
const containerRectCheck = scrollableContainer.getBoundingClientRect();
|
|
736
|
+
const stillAbove = rectCheck.top < containerRectCheck.top;
|
|
737
|
+
const stillBelow = rectCheck.bottom > containerRectCheck.bottom;
|
|
738
|
+
|
|
739
|
+
if (stillAbove || stillBelow) {
|
|
740
|
+
element.scrollIntoView({
|
|
741
|
+
behavior: 'auto',
|
|
742
|
+
block: stillAbove ? 'start' : 'end',
|
|
743
|
+
inline: 'nearest',
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
}, 0);
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Debounce helper for search-while-typing
|
|
751
|
+
*/
|
|
752
|
+
debounce: function (func, wait) {
|
|
753
|
+
let timeout;
|
|
754
|
+
return function executedFunction(...args) {
|
|
755
|
+
const later = () => {
|
|
756
|
+
clearTimeout(timeout);
|
|
757
|
+
func(...args);
|
|
758
|
+
};
|
|
759
|
+
clearTimeout(timeout);
|
|
760
|
+
timeout = setTimeout(later, wait);
|
|
761
|
+
};
|
|
762
|
+
},
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Sets up a search input element with automatic search on typing.
|
|
766
|
+
* Attaches debounced input event handler and manages search lifecycle.
|
|
767
|
+
* @memberof SearchBoxClient.SearchBox
|
|
768
|
+
* @param {string} inputId - Input element ID or class name.
|
|
769
|
+
* @param {string} resultsContainerId - Results container element ID or class name.
|
|
770
|
+
* @param {object} [context={}] - Configuration context object.
|
|
771
|
+
* @param {number} [context.debounceTime=300] - Debounce delay in milliseconds.
|
|
772
|
+
* @param {number} [context.minQueryLength=1] - Minimum query length to trigger search.
|
|
773
|
+
* @returns {Function} Cleanup function to remove event listeners.
|
|
774
|
+
*/
|
|
775
|
+
setupSearchInput: function (inputId, resultsContainerId, context = {}) {
|
|
776
|
+
const input = s(`#${inputId}`) || s(`.${inputId}`);
|
|
777
|
+
if (!input) {
|
|
778
|
+
logger.warn(`Input ${inputId} not found`);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const debounceTime = context.debounceTime || 300;
|
|
783
|
+
|
|
784
|
+
const performSearch = this.debounce(async (query) => {
|
|
785
|
+
const trimmedQuery = query ? query.trim() : '';
|
|
786
|
+
const minLength = context.minQueryLength !== undefined ? context.minQueryLength : 1;
|
|
787
|
+
|
|
788
|
+
// Show recent results when query is empty
|
|
789
|
+
if (trimmedQuery.length === 0) {
|
|
790
|
+
const recentResults = this.RecentResults.getAll();
|
|
791
|
+
this.renderResults(recentResults, resultsContainerId, context);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Support single character searches by default (minQueryLength: 1)
|
|
796
|
+
// Can be configured via context.minQueryLength for different use cases
|
|
797
|
+
if (trimmedQuery.length < minLength) {
|
|
798
|
+
this.renderResults([], resultsContainerId, context);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const results = await this.search(trimmedQuery, context);
|
|
803
|
+
this.renderResults(results, resultsContainerId, context);
|
|
804
|
+
}, debounceTime);
|
|
805
|
+
|
|
806
|
+
// Store the handler reference
|
|
807
|
+
const handlerId = `search-handler-${inputId}`;
|
|
808
|
+
if (this.Data[handlerId]) {
|
|
809
|
+
input.removeEventListener('input', this.Data[handlerId]);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
this.Data[handlerId] = (e) => {
|
|
813
|
+
performSearch(e.target.value);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
input.addEventListener('input', this.Data[handlerId]);
|
|
817
|
+
|
|
818
|
+
logger.info(`Setup search input: ${inputId}`);
|
|
819
|
+
|
|
820
|
+
return () => {
|
|
821
|
+
input.removeEventListener('input', this.Data[handlerId]);
|
|
822
|
+
delete this.Data[handlerId];
|
|
823
|
+
};
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Debounces a function call to reduce excessive invocations.
|
|
828
|
+
* Used for search input to prevent searching on every keystroke.
|
|
829
|
+
* @memberof SearchBoxClient.SearchBox
|
|
830
|
+
* @param {Function} func - The function to debounce.
|
|
831
|
+
* @param {number} wait - Delay in milliseconds before invoking the function.
|
|
832
|
+
* @returns {Function} Debounced function that delays invocation.
|
|
833
|
+
*/
|
|
834
|
+
debounce: function (func, wait) {
|
|
835
|
+
let timeout;
|
|
836
|
+
|
|
837
|
+
const later = function (...args) {
|
|
838
|
+
timeout = null;
|
|
839
|
+
func(...args);
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
return function (...args) {
|
|
843
|
+
if (timeout) clearTimeout(timeout);
|
|
844
|
+
timeout = setTimeout(() => later(...args), wait);
|
|
845
|
+
};
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Clears all registered search providers.
|
|
850
|
+
* Useful for cleanup or resetting search functionality.
|
|
851
|
+
* @memberof SearchBoxClient.SearchBox
|
|
852
|
+
* @returns {void}
|
|
853
|
+
*/
|
|
854
|
+
clearProviders: function () {
|
|
855
|
+
this.providers = [];
|
|
856
|
+
logger.info('Cleared all search providers');
|
|
857
|
+
},
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Gets base CSS styles for SearchBox with theme-aware styling.
|
|
861
|
+
* Uses subThemeManager colors for consistent theming across light and dark modes.
|
|
862
|
+
* Styles include search result items, icons, tags, and active states.
|
|
863
|
+
* @memberof SearchBoxClient.SearchBox
|
|
864
|
+
* @returns {string} CSS string containing all base SearchBox styles.
|
|
865
|
+
*/
|
|
866
|
+
getBaseStyles: () => {
|
|
867
|
+
// Get theme color from subThemeManager
|
|
868
|
+
const themeColor = darkTheme ? subThemeManager.darkColor : subThemeManager.lightColor;
|
|
869
|
+
const hasThemeColor = themeColor && themeColor !== null;
|
|
870
|
+
|
|
871
|
+
// Calculate theme-based colors
|
|
872
|
+
let activeBg, activeBorder, hoverBg, iconColor, tagBg, tagColor, tagBorder;
|
|
873
|
+
|
|
874
|
+
if (darkTheme) {
|
|
875
|
+
// Dark theme styling - solid white icons for better visibility
|
|
876
|
+
iconColor = '#ffffff';
|
|
877
|
+
if (hasThemeColor) {
|
|
878
|
+
activeBg = darkenHex(themeColor, 0.7);
|
|
879
|
+
activeBorder = lightenHex(themeColor, 0.4);
|
|
880
|
+
hoverBg = `${darkenHex(themeColor, 0.8)}33`; // 20% opacity
|
|
881
|
+
tagBg = darkenHex(themeColor, 0.6);
|
|
882
|
+
tagColor = lightenHex(themeColor, 0.7);
|
|
883
|
+
tagBorder = lightenHex(themeColor, 0.3);
|
|
884
|
+
} else {
|
|
885
|
+
activeBg = '#2a2a2a';
|
|
886
|
+
activeBorder = '#444';
|
|
887
|
+
hoverBg = 'rgba(255, 255, 255, 0.05)';
|
|
888
|
+
tagBg = '#333';
|
|
889
|
+
tagColor = '#aaa';
|
|
890
|
+
tagBorder = '#555';
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
// Light theme styling - solid black icons for better visibility
|
|
894
|
+
iconColor = '#000000';
|
|
895
|
+
if (hasThemeColor) {
|
|
896
|
+
activeBg = lightenHex(themeColor, 0.85);
|
|
897
|
+
activeBorder = lightenHex(themeColor, 0.5);
|
|
898
|
+
hoverBg = `${lightenHex(themeColor, 0.9)}33`; // 20% opacity
|
|
899
|
+
tagBg = lightenHex(themeColor, 0.8);
|
|
900
|
+
tagColor = darkenHex(themeColor, 0.3);
|
|
901
|
+
tagBorder = lightenHex(themeColor, 0.6);
|
|
902
|
+
} else {
|
|
903
|
+
activeBg = '#f0f0f0';
|
|
904
|
+
activeBorder = '#ccc';
|
|
905
|
+
hoverBg = 'rgba(0, 0, 0, 0.05)';
|
|
906
|
+
tagBg = '#e8e8e8';
|
|
907
|
+
tagColor = '#555';
|
|
908
|
+
tagBorder = '#d0d0d0';
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return css`
|
|
913
|
+
/* Search result items - simplified, consistent borders */
|
|
914
|
+
.search-result-item {
|
|
915
|
+
display: flex;
|
|
916
|
+
align-items: center;
|
|
917
|
+
gap: 10px;
|
|
918
|
+
padding: 12px 14px;
|
|
919
|
+
margin: 4px 0;
|
|
920
|
+
cursor: pointer;
|
|
921
|
+
border-radius: 4px;
|
|
922
|
+
transition: all 0.15s ease;
|
|
923
|
+
border: 1px solid transparent;
|
|
924
|
+
background: transparent;
|
|
925
|
+
min-height: 44px;
|
|
926
|
+
box-sizing: border-box;
|
|
927
|
+
width: 100%;
|
|
928
|
+
max-width: 100%;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
.search-result-item:hover {
|
|
932
|
+
background: ${hoverBg};
|
|
933
|
+
border-color: ${activeBorder}44;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.search-result-item.active-search-result,
|
|
937
|
+
.search-result-item.main-btn-menu-active {
|
|
938
|
+
background: ${activeBg} !important;
|
|
939
|
+
border: 1px solid ${activeBorder} !important;
|
|
940
|
+
box-shadow: 0 0 0 1px ${activeBorder}66 !important;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
.search-result-route {
|
|
944
|
+
padding: 10px 12px;
|
|
945
|
+
margin: 2px;
|
|
946
|
+
text-align: left;
|
|
947
|
+
min-height: 40px;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
.search-result-icon {
|
|
951
|
+
display: flex;
|
|
952
|
+
align-items: center;
|
|
953
|
+
justify-content: center;
|
|
954
|
+
min-width: 28px;
|
|
955
|
+
min-height: 28px;
|
|
956
|
+
font-size: 16px;
|
|
957
|
+
color: ${iconColor} !important;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.search-result-icon i {
|
|
961
|
+
color: ${iconColor} !important;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.search-result-icon .fa,
|
|
965
|
+
.search-result-icon .fas,
|
|
966
|
+
.search-result-icon .far,
|
|
967
|
+
.search-result-icon .fab {
|
|
968
|
+
color: ${iconColor} !important;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
.search-result-icon img {
|
|
972
|
+
width: 25px;
|
|
973
|
+
height: 25px;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.search-result-content {
|
|
977
|
+
flex: 1;
|
|
978
|
+
min-width: 0;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
.search-result-title {
|
|
982
|
+
font-size: 14px;
|
|
983
|
+
font-weight: 500;
|
|
984
|
+
margin-bottom: 2px;
|
|
985
|
+
line-height: 1.4;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
.search-result-subtitle {
|
|
989
|
+
font-size: 12px;
|
|
990
|
+
color: ${darkTheme ? '#999' : '#666'};
|
|
991
|
+
margin-top: 2px;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* Tags/Badges - themed with subThemeManager colors */
|
|
995
|
+
.search-result-tag,
|
|
996
|
+
.search-result-badge {
|
|
997
|
+
display: inline-block;
|
|
998
|
+
padding: 2px 8px;
|
|
999
|
+
margin: 2px 4px 2px 0;
|
|
1000
|
+
font-size: 11px;
|
|
1001
|
+
border-radius: 3px;
|
|
1002
|
+
background: ${tagBg};
|
|
1003
|
+
color: ${tagColor};
|
|
1004
|
+
border: 1px solid ${tagBorder};
|
|
1005
|
+
white-space: nowrap;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
.search-result-tags {
|
|
1009
|
+
display: flex;
|
|
1010
|
+
flex-wrap: wrap;
|
|
1011
|
+
gap: 4px;
|
|
1012
|
+
margin-top: 4px;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/* Active item tags have stronger accent */
|
|
1016
|
+
.search-result-item.active-search-result .search-result-tag,
|
|
1017
|
+
.search-result-item.active-search-result .search-result-badge {
|
|
1018
|
+
background: ${hasThemeColor ? (darkTheme ? darkenHex(themeColor, 0.5) : lightenHex(themeColor, 0.75)) : tagBg};
|
|
1019
|
+
border-color: ${activeBorder};
|
|
1020
|
+
color: ${hasThemeColor ? (darkTheme ? lightenHex(themeColor, 0.8) : darkenHex(themeColor, 0.4)) : tagColor};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/* Wrapper for history items with delete button - maintains original width */
|
|
1024
|
+
.search-result-history-item {
|
|
1025
|
+
position: relative;
|
|
1026
|
+
box-sizing: border-box;
|
|
1027
|
+
width: 100%;
|
|
1028
|
+
max-width: 100%;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.search-result-history-item .search-result-item {
|
|
1032
|
+
width: 100%;
|
|
1033
|
+
box-sizing: border-box;
|
|
1034
|
+
padding-right: 30px; /* Make room for delete button */
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/* Delete button - absolute positioned in top-right corner */
|
|
1038
|
+
.search-result-delete-btn {
|
|
1039
|
+
position: absolute;
|
|
1040
|
+
top: 4px;
|
|
1041
|
+
right: 4px;
|
|
1042
|
+
background: none;
|
|
1043
|
+
border: none;
|
|
1044
|
+
color: #999;
|
|
1045
|
+
cursor: pointer;
|
|
1046
|
+
padding: 3px 6px;
|
|
1047
|
+
border-radius: 3px;
|
|
1048
|
+
transition: all 0.2s ease;
|
|
1049
|
+
opacity: 0;
|
|
1050
|
+
width: 22px;
|
|
1051
|
+
height: 22px;
|
|
1052
|
+
display: flex;
|
|
1053
|
+
align-items: center;
|
|
1054
|
+
justify-content: center;
|
|
1055
|
+
font-size: 10px;
|
|
1056
|
+
z-index: 5;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.search-result-history-item:hover .search-result-delete-btn {
|
|
1060
|
+
opacity: 1;
|
|
1061
|
+
color: ${darkTheme ? '#ff8a8a' : '#e53935'};
|
|
1062
|
+
background: ${darkTheme ? 'rgba(255, 107, 107, 0.2)' : 'rgba(211, 47, 47, 0.15)'};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.search-result-delete-btn:hover {
|
|
1066
|
+
background: ${darkTheme ? 'rgba(255, 107, 107, 0.35)' : 'rgba(211, 47, 47, 0.25)'};
|
|
1067
|
+
transform: scale(1.1);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.search-result-delete-btn:active {
|
|
1071
|
+
transform: scale(0.95);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/* Animation for removal */
|
|
1075
|
+
.search-result-history-item.search-result-removing {
|
|
1076
|
+
opacity: 0;
|
|
1077
|
+
transform: translateX(20px);
|
|
1078
|
+
transition: all 0.2s ease;
|
|
1079
|
+
}
|
|
1080
|
+
`;
|
|
1081
|
+
},
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Injects base SearchBox styles into the document head.
|
|
1085
|
+
* Creates a style tag if it doesn't exist, ensuring styles are loaded once.
|
|
1086
|
+
* Automatically called when SearchBox is first used.
|
|
1087
|
+
* @memberof SearchBoxClient.SearchBox
|
|
1088
|
+
* @returns {void}
|
|
1089
|
+
*/
|
|
1090
|
+
injectStyles: function () {
|
|
1091
|
+
const styleId = 'search-box-base-styles';
|
|
1092
|
+
let styleTag = document.getElementById(styleId);
|
|
1093
|
+
|
|
1094
|
+
if (!styleTag) {
|
|
1095
|
+
styleTag = document.createElement('style');
|
|
1096
|
+
styleTag.id = styleId;
|
|
1097
|
+
document.head.appendChild(styleTag);
|
|
1098
|
+
logger.info('Injected SearchBox base styles');
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Always update styles (for theme changes and subThemeManager color changes)
|
|
1102
|
+
styleTag.textContent = this.getBaseStyles();
|
|
1103
|
+
|
|
1104
|
+
// Register theme change handler if not already registered
|
|
1105
|
+
if (typeof ThemeEvents !== 'undefined' && !ThemeEvents['searchBoxBaseStyles']) {
|
|
1106
|
+
ThemeEvents['searchBoxBaseStyles'] = () => {
|
|
1107
|
+
const tag = document.getElementById(styleId);
|
|
1108
|
+
if (tag) {
|
|
1109
|
+
tag.textContent = this.getBaseStyles();
|
|
1110
|
+
logger.info('Updated SearchBox styles for theme change');
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
};
|
|
1116
|
+
|
|
1117
|
+
export { SearchBox };
|