@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.
Files changed (78) hide show
  1. package/README.md +2 -2
  2. package/baremetal/commission-workflows.json +33 -3
  3. package/bin/deploy.js +1 -1
  4. package/cli.md +7 -2
  5. package/conf.js +3 -0
  6. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  7. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  8. package/package.json +1 -1
  9. package/packer/scripts/fuse-tar-root +3 -3
  10. package/scripts/disk-clean.sh +23 -23
  11. package/scripts/gpu-diag.sh +2 -2
  12. package/scripts/ip-info.sh +11 -11
  13. package/scripts/maas-upload-boot-resource.sh +1 -1
  14. package/scripts/nvim.sh +1 -1
  15. package/scripts/packer-setup.sh +13 -13
  16. package/scripts/rocky-setup.sh +2 -2
  17. package/scripts/rpmfusion-ffmpeg-setup.sh +4 -4
  18. package/scripts/ssl.sh +7 -7
  19. package/src/api/core/core.service.js +0 -5
  20. package/src/api/default/default.service.js +7 -5
  21. package/src/api/document/document.model.js +30 -1
  22. package/src/api/document/document.router.js +6 -0
  23. package/src/api/document/document.service.js +423 -51
  24. package/src/api/file/file.model.js +112 -4
  25. package/src/api/file/file.ref.json +42 -0
  26. package/src/api/file/file.service.js +380 -32
  27. package/src/api/user/user.model.js +38 -1
  28. package/src/api/user/user.router.js +96 -63
  29. package/src/api/user/user.service.js +81 -48
  30. package/src/cli/baremetal.js +689 -329
  31. package/src/cli/cluster.js +50 -52
  32. package/src/cli/db.js +424 -166
  33. package/src/cli/deploy.js +1 -1
  34. package/src/cli/index.js +12 -1
  35. package/src/cli/lxd.js +3 -3
  36. package/src/cli/repository.js +1 -1
  37. package/src/cli/run.js +2 -1
  38. package/src/cli/ssh.js +10 -10
  39. package/src/client/components/core/Account.js +327 -36
  40. package/src/client/components/core/AgGrid.js +3 -0
  41. package/src/client/components/core/Auth.js +9 -3
  42. package/src/client/components/core/Chat.js +2 -2
  43. package/src/client/components/core/Content.js +159 -78
  44. package/src/client/components/core/Css.js +16 -2
  45. package/src/client/components/core/CssCore.js +16 -12
  46. package/src/client/components/core/FileExplorer.js +115 -8
  47. package/src/client/components/core/Input.js +204 -11
  48. package/src/client/components/core/LogIn.js +42 -20
  49. package/src/client/components/core/Modal.js +257 -177
  50. package/src/client/components/core/Panel.js +324 -27
  51. package/src/client/components/core/PanelForm.js +280 -73
  52. package/src/client/components/core/PublicProfile.js +888 -0
  53. package/src/client/components/core/Router.js +117 -15
  54. package/src/client/components/core/SearchBox.js +1117 -0
  55. package/src/client/components/core/SignUp.js +26 -7
  56. package/src/client/components/core/SocketIo.js +6 -3
  57. package/src/client/components/core/Translate.js +98 -0
  58. package/src/client/components/core/Validator.js +15 -0
  59. package/src/client/components/core/windowGetDimensions.js +6 -6
  60. package/src/client/components/default/MenuDefault.js +59 -12
  61. package/src/client/components/default/RoutesDefault.js +1 -0
  62. package/src/client/services/core/core.service.js +163 -1
  63. package/src/client/services/default/default.management.js +451 -64
  64. package/src/client/services/default/default.service.js +13 -6
  65. package/src/client/services/document/document.service.js +23 -0
  66. package/src/client/services/file/file.service.js +43 -16
  67. package/src/client/services/user/user.service.js +13 -9
  68. package/src/db/DataBaseProvider.js +1 -1
  69. package/src/db/mongo/MongooseDB.js +1 -1
  70. package/src/index.js +1 -1
  71. package/src/mailer/MailerProvider.js +4 -4
  72. package/src/runtime/express/Express.js +2 -1
  73. package/src/runtime/lampp/Lampp.js +2 -2
  74. package/src/server/auth.js +3 -6
  75. package/src/server/data-query.js +449 -0
  76. package/src/server/dns.js +4 -4
  77. package/src/server/object-layer.js +0 -3
  78. 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 };