@underpostnet/underpost 2.97.1 → 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 (59) hide show
  1. package/README.md +2 -2
  2. package/cli.md +3 -1
  3. package/conf.js +2 -0
  4. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  5. package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
  6. package/package.json +1 -1
  7. package/src/api/core/core.service.js +0 -5
  8. package/src/api/default/default.service.js +7 -5
  9. package/src/api/document/document.model.js +1 -1
  10. package/src/api/document/document.router.js +5 -0
  11. package/src/api/document/document.service.js +105 -47
  12. package/src/api/file/file.model.js +112 -4
  13. package/src/api/file/file.ref.json +42 -0
  14. package/src/api/file/file.service.js +380 -32
  15. package/src/api/user/user.model.js +38 -1
  16. package/src/api/user/user.router.js +96 -63
  17. package/src/api/user/user.service.js +81 -48
  18. package/src/cli/db.js +424 -166
  19. package/src/cli/index.js +8 -0
  20. package/src/cli/repository.js +1 -1
  21. package/src/cli/run.js +1 -0
  22. package/src/cli/ssh.js +10 -10
  23. package/src/client/components/core/Account.js +327 -36
  24. package/src/client/components/core/AgGrid.js +3 -0
  25. package/src/client/components/core/Auth.js +9 -3
  26. package/src/client/components/core/Chat.js +2 -2
  27. package/src/client/components/core/Content.js +159 -78
  28. package/src/client/components/core/CssCore.js +16 -12
  29. package/src/client/components/core/FileExplorer.js +115 -8
  30. package/src/client/components/core/Input.js +204 -11
  31. package/src/client/components/core/LogIn.js +42 -20
  32. package/src/client/components/core/Modal.js +138 -24
  33. package/src/client/components/core/Panel.js +69 -31
  34. package/src/client/components/core/PanelForm.js +262 -77
  35. package/src/client/components/core/PublicProfile.js +888 -0
  36. package/src/client/components/core/Router.js +117 -15
  37. package/src/client/components/core/SearchBox.js +329 -13
  38. package/src/client/components/core/SignUp.js +26 -7
  39. package/src/client/components/core/SocketIo.js +6 -3
  40. package/src/client/components/core/Translate.js +98 -0
  41. package/src/client/components/core/Validator.js +15 -0
  42. package/src/client/components/core/windowGetDimensions.js +6 -6
  43. package/src/client/components/default/MenuDefault.js +59 -12
  44. package/src/client/components/default/RoutesDefault.js +1 -0
  45. package/src/client/services/core/core.service.js +163 -1
  46. package/src/client/services/default/default.management.js +451 -64
  47. package/src/client/services/default/default.service.js +13 -6
  48. package/src/client/services/file/file.service.js +43 -16
  49. package/src/client/services/user/user.service.js +13 -9
  50. package/src/db/DataBaseProvider.js +1 -1
  51. package/src/db/mongo/MongooseDB.js +1 -1
  52. package/src/index.js +1 -1
  53. package/src/mailer/MailerProvider.js +4 -4
  54. package/src/runtime/express/Express.js +2 -1
  55. package/src/runtime/lampp/Lampp.js +2 -2
  56. package/src/server/auth.js +3 -6
  57. package/src/server/data-query.js +449 -0
  58. package/src/server/object-layer.js +0 -3
  59. package/src/ws/IoInterface.js +2 -2
@@ -55,17 +55,24 @@ const getProxyPath = () => {
55
55
 
56
56
  /**
57
57
  * Sets the browser's path using the History API. It sanitizes the path, handles query strings and hashes,
58
- * and prevents pushing the same state twice.
58
+ * and prevents pushing the same state twice unless forced or using replace mode.
59
59
  * @param {string} [path='/'] - The new path to set. Can include query strings and hashes.
60
- * @param {object} [options={ removeSearch: false, removeHash: false }] - Options for path manipulation.
60
+ * @param {object} [options={ removeSearch: false, removeHash: false, replace: false, force: false }] - Options for path manipulation.
61
61
  * @param {boolean} [options.removeSearch=false] - If true, removes the search part of the URL.
62
62
  * @param {boolean} [options.removeHash=false] - If true, removes the hash part of the URL. Defaults to `false`.
63
+ * @param {boolean} [options.replace=false] - If true, uses replaceState instead of pushState.
64
+ * @param {boolean} [options.force=false] - If true, allows navigation to the same path (useful for query param changes).
63
65
  * @param {object} [stateStorage={}] - State object to associate with the history entry.
64
66
  * @param {string} [title=''] - The title for the new history entry.
65
67
  * @memberof PwaRouter
66
- * @returns {void | undefined} Returns `undefined` if the new path is the same as the current path, otherwise `void` (result of `history.pushState`).
68
+ * @returns {void | undefined} Returns `undefined` if the new path is the same as the current path and not forced, otherwise `void` (result of `history.pushState` or `history.replaceState`).
67
69
  */
68
- const setPath = (path = '/', options = { removeSearch: false, removeHash: false }, stateStorage = {}, title = '') => {
70
+ const setPath = (
71
+ path = '/',
72
+ options = { removeSearch: false, removeHash: false, replace: false, force: false },
73
+ stateStorage = {},
74
+ title = '',
75
+ ) => {
69
76
  // logger.warn(`Set path input`, `${path}`);
70
77
  if (!path) path = '/';
71
78
 
@@ -94,11 +101,16 @@ const setPath = (path = '/', options = { removeSearch: false, removeHash: false
94
101
  // currentFullPath,
95
102
  // newFullPath,
96
103
  // });
97
- if (currentFullPath === newFullPath) {
104
+ if (currentFullPath === newFullPath && !options.force) {
98
105
  // logger.warn('Prevent overwriting same path', { currentFullPath, newFullPath });
99
106
  return;
100
107
  }
101
- return history.pushState.call(history, stateStorage, title, newFullPath);
108
+
109
+ if (options.replace) {
110
+ return history.replaceState.call(history, stateStorage, title, newFullPath);
111
+ } else {
112
+ return history.pushState.call(history, stateStorage, title, newFullPath);
113
+ }
102
114
  };
103
115
 
104
116
  /**
@@ -139,13 +151,15 @@ const sanitizeRoute = (route) =>
139
151
  * @memberof PwaRouter
140
152
  */
141
153
  const setDocTitle = (route) => {
142
- const _route = sanitizeRoute(route);
154
+ let _route = sanitizeRoute(route);
143
155
  // logger.warn('setDocTitle', _route);
144
156
  const title = titleFormatted(_route);
145
157
  htmls('title', html`${title}${title.match(Worker.title.toLowerCase()) ? '' : ` | ${Worker.title}`}`);
146
- if (s(`.main-btn-${_route}`)) {
158
+
159
+ const btnSelector = _route === 'u' ? 'public-profile' : _route;
160
+ if (s(`.main-btn-${btnSelector}`)) {
147
161
  if (s(`.main-btn-menu-active`)) s(`.main-btn-menu-active`).classList.remove(`main-btn-menu-active`);
148
- if (s(`.main-btn-${_route}`)) s(`.main-btn-${_route}`).classList.add(`main-btn-menu-active`);
162
+ if (s(`.main-btn-${btnSelector}`)) s(`.main-btn-${btnSelector}`).classList.add(`main-btn-menu-active`);
149
163
  }
150
164
  };
151
165
 
@@ -168,11 +182,19 @@ const Router = function (options = { Routes: () => {}, e: new PopStateEvent() })
168
182
  let pushPath = `${proxyPath}${route}`;
169
183
 
170
184
  if (path[path.length - 1] !== '/') path = `${path}/`;
185
+ // Handle clean profile URLs: match /u/username with /u route
186
+ let matchPath = path;
187
+ if (route === 'u' && path.startsWith(`${proxyPath}u/`) && path !== `${proxyPath}u/`) {
188
+ handleCleanProfileUrl(path);
189
+ matchPath = `${proxyPath}u/`;
190
+ }
191
+
192
+ if (matchPath[matchPath.length - 1] !== '/') matchPath = `${matchPath}/`;
171
193
  if (pushPath[pushPath.length - 1] !== '/') pushPath = `${pushPath}/`;
172
194
 
173
- const routerEvent = { path, pushPath, route };
195
+ const routerEvent = { path: matchPath, pushPath, route };
174
196
 
175
- if (path === pushPath) {
197
+ if (matchPath === pushPath) {
176
198
  for (const event of Object.keys(RouterEvents)) RouterEvents[event](routerEvent);
177
199
  subMenuHandler(Object.keys(Routes()), route);
178
200
  setDocTitle(route);
@@ -206,15 +228,81 @@ const LoadRouter = function (RouterInstance) {
206
228
  * This function constructs a new URI based on the proxy path, a given path, and an optional query parameter.
207
229
  * @param {object} [options={ path: '', queryPath: '' }] - The path options.
208
230
  * @param {string} [options.path=''] - The base path segment.
231
+ * @param {string} [options.queryPath=''] - The query parameter value.
232
+ * @param {string} [queryKey='cid'] - The query parameter key.
209
233
  * @memberof PwaRouter
210
234
  */
211
- const setQueryPath = (options = { path: '', queryPath: '' }, queryKey = 'cid') => {
235
+ const setQueryPath = (options = { path: '', queryPath: '' }, queryKey = 'cid', navOptions = {}) => {
212
236
  const { queryPath, path } = options;
213
- const newUri = `${getProxyPath()}${path === 'home' ? '' : `${path}/`}${
237
+ const { replace = false } = navOptions;
238
+ const newUri = `${getProxyPath()}${path === 'home' ? '' : `${path}`}${
214
239
  typeof queryPath === 'string' && queryPath ? `?${queryKey}=${queryPath}` : ''
215
240
  }`;
216
241
  const currentUri = `${window.location.pathname}${location.search}`;
217
- if (currentUri !== newUri && currentUri !== `${newUri}/`) setPath(newUri, {}, '');
242
+
243
+ // For query parameter changes on the same path, force the navigation to ensure proper history
244
+ const isSamePath = window.location.pathname === new URL(newUri, window.location.origin).pathname;
245
+ const isDifferentQuery = window.location.search !== new URL(newUri, window.location.origin).search;
246
+ const shouldForce = isSamePath && isDifferentQuery;
247
+
248
+ if (currentUri !== newUri && currentUri !== `${newUri}/`) {
249
+ setPath(newUri, { force: shouldForce, replace }, '');
250
+ }
251
+ };
252
+
253
+ /**
254
+ * Extracts username from clean public profile URLs like /u/username.
255
+ * @param {string} [pathname] - The pathname to extract from (defaults to current pathname).
256
+ * @returns {string|null} The username if found, null otherwise.
257
+ * @memberof PwaRouter
258
+ */
259
+ const extractUsernameFromPath = (pathname = window.location.pathname) => {
260
+ const proxyPath = getProxyPath();
261
+ const cleanPathPrefix = `${proxyPath}u/`.replace(/\/+/g, '/');
262
+
263
+ if (pathname.startsWith(cleanPathPrefix)) {
264
+ const username = pathname.slice(cleanPathPrefix.length).split('/')[0];
265
+ return username || null;
266
+ }
267
+ return null;
268
+ };
269
+
270
+ /**
271
+ * Handles direct navigation to clean public profile URLs.
272
+ * Converts clean URLs like /u/username to internal query format for SPA.
273
+ * @param {string} [pathname] - The pathname to handle (defaults to current pathname).
274
+ * @returns {boolean} True if this was a public profile URL that was handled.
275
+ * @memberof PwaRouter
276
+ */
277
+ const handleCleanProfileUrl = (pathname = window.location.pathname) => {
278
+ const username = extractUsernameFromPath(pathname);
279
+ if (username) {
280
+ // Convert clean URL to internal query format for data fetching
281
+ // Don't modify history - just return the username for the caller to use
282
+ return username;
283
+ }
284
+ return null;
285
+ };
286
+
287
+ /**
288
+ * Navigates to a public profile URL without adding intermediate query URLs to history.
289
+ * This ensures clean back/forward navigation between profiles.
290
+ * @param {string} username - The username to navigate to.
291
+ * @param {object} [options={}] - Navigation options.
292
+ * @param {boolean} [options.replace=false] - If true, replaces current history entry instead of pushing.
293
+ * @memberof PwaRouter
294
+ */
295
+ const navigateToProfile = (username, options = {}) => {
296
+ const { replace = false } = options;
297
+ if (!username) return;
298
+
299
+ const cleanPath = `${getProxyPath()}u/${username}`;
300
+ const currentPath = window.location.pathname;
301
+
302
+ // If we're already on this profile's clean URL, no navigation needed
303
+ if (currentPath === cleanPath) return;
304
+ // Navigate directly to clean URL, avoiding intermediate ?cid= URLs in history
305
+ setPath(cleanPath, { replace });
218
306
  };
219
307
 
220
308
  /**
@@ -304,7 +392,12 @@ const handleModalViewRoute = (options = { RouterInstance: { Routes: () => {} },
304
392
  const newPath = `${proxyPath}${route}`;
305
393
  if (RouterInstance && RouterInstance.Routes) subMenuHandler(Object.keys(RouterInstance.Routes()), route);
306
394
 
307
- if (path !== newPath) {
395
+ // Check if we're already on this route or a sub-path of it (e.g., /u/username for route 'u')
396
+ // Don't push to history if already on the route or its sub-path
397
+ const routeBasePath = `${proxyPath}${route}`;
398
+ const isOnRouteOrSubPath = path === newPath || path.startsWith(`${routeBasePath}/`);
399
+
400
+ if (!isOnRouteOrSubPath) {
308
401
  setPath(newPath);
309
402
  setDocTitle(newPath);
310
403
  }
@@ -331,6 +424,12 @@ const setQueryParams = (newParams, options = { replace: true }) => {
331
424
  });
332
425
 
333
426
  const newPath = url.pathname + url.search + url.hash;
427
+ const currentPath = window.location.pathname + window.location.search + window.location.hash;
428
+
429
+ // Only update history and trigger listeners if the URL actually changed
430
+ if (newPath === currentPath) {
431
+ return;
432
+ }
334
433
 
335
434
  if (options.replace) {
336
435
  history.replaceState(history.state, '', newPath);
@@ -348,12 +447,15 @@ const setQueryParams = (newParams, options = { replace: true }) => {
348
447
 
349
448
  export {
350
449
  RouterEvents,
450
+ navigateToProfile,
351
451
  closeModalRouteChangeEvents,
352
452
  coreUI,
353
453
  Router,
354
454
  setDocTitle,
355
455
  LoadRouter,
356
456
  setQueryPath,
457
+ extractUsernameFromPath,
458
+ handleCleanProfileUrl,
357
459
  listenQueryPathInstance,
358
460
  closeModalRouteChangeEvent,
359
461
  handleModalViewRoute,
@@ -34,12 +34,133 @@ const SearchBox = {
34
34
  * - search: async (query, context) => Promise<Array<result>>
35
35
  * - renderResult: (result, index, context) => string (HTML)
36
36
  * - onClick: (result, context) => void
37
- * - priority: number (lower = higher priority)
37
+ * - priority: number (lower number = higher priority)
38
38
  * @type {Array<object>}
39
39
  * @memberof SearchBoxClient.SearchBox
40
40
  */
41
41
  providers: [],
42
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
+
43
164
  /**
44
165
  * Registers a search provider plugin for extensible search functionality.
45
166
  * Replaces any existing provider with the same ID.
@@ -290,24 +411,56 @@ const SearchBox = {
290
411
  return;
291
412
  }
292
413
 
293
- let html = '';
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 = '';
294
419
  results.forEach((result, index) => {
295
420
  const provider = this.providers.find((p) => p.id === result.providerId);
296
421
 
422
+ let resultHtml = '';
297
423
  if (result.type === 'route' || !provider) {
298
424
  // Default route rendering (backward compatible)
299
- html += this.renderRouteResult(result, index, context);
425
+ resultHtml = this.renderRouteResult(result, index, context);
300
426
  } else {
301
427
  // Custom provider rendering
302
- html += provider.renderResult(result, index, context);
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;
303
451
  }
304
452
  });
305
453
 
306
- container.innerHTML = html;
454
+ container.innerHTML = htmlContent;
307
455
 
308
456
  // Attach click handlers
309
457
  this.attachClickHandlers(results, containerId, context);
310
458
 
459
+ // Only attach delete handlers for recently clicked items
460
+ if (isRecentHistory) {
461
+ this.attachDeleteHandlers(container, results, containerId, context);
462
+ }
463
+
311
464
  // Call post-render callbacks from providers
312
465
  results.forEach((result) => {
313
466
  const provider = this.providers.find((p) => p.id === result.providerId);
@@ -317,6 +470,68 @@ const SearchBox = {
317
470
  });
318
471
  },
319
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
+
320
535
  /**
321
536
  * Renders a default route search result.
322
537
  * Backward compatible with Modal.js search functionality.
@@ -338,10 +553,35 @@ const SearchBox = {
338
553
  const imgElement = result.imgElement;
339
554
 
340
555
  let iconHtml = '';
341
- if (imgElement) {
342
- iconHtml = imgElement.outerHTML;
343
- } else if (fontAwesomeIcon) {
344
- iconHtml = fontAwesomeIcon.outerHTML;
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
+ }
345
585
  }
346
586
 
347
587
  const translatedText = Translate.Render(routerId);
@@ -381,6 +621,9 @@ const SearchBox = {
381
621
  e.preventDefault();
382
622
  e.stopPropagation();
383
623
 
624
+ // Track result in persistent history for all result types
625
+ this.RecentResults.add(result);
626
+
384
627
  const provider = this.providers.find((p) => p.id === result.providerId);
385
628
 
386
629
  if (result.type === 'route') {
@@ -542,6 +785,13 @@ const SearchBox = {
542
785
  const trimmedQuery = query ? query.trim() : '';
543
786
  const minLength = context.minQueryLength !== undefined ? context.minQueryLength : 1;
544
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
+
545
795
  // Support single character searches by default (minQueryLength: 1)
546
796
  // Can be configured via context.minQueryLength for different use cases
547
797
  if (trimmedQuery.length < minLength) {
@@ -665,13 +915,17 @@ const SearchBox = {
665
915
  display: flex;
666
916
  align-items: center;
667
917
  gap: 10px;
668
- padding: 8px 10px;
918
+ padding: 12px 14px;
669
919
  margin: 4px 0;
670
920
  cursor: pointer;
671
921
  border-radius: 4px;
672
922
  transition: all 0.15s ease;
673
923
  border: 1px solid transparent;
674
924
  background: transparent;
925
+ min-height: 44px;
926
+ box-sizing: border-box;
927
+ width: 100%;
928
+ max-width: 100%;
675
929
  }
676
930
 
677
931
  .search-result-item:hover {
@@ -687,16 +941,19 @@ const SearchBox = {
687
941
  }
688
942
 
689
943
  .search-result-route {
690
- padding: 3px;
944
+ padding: 10px 12px;
691
945
  margin: 2px;
692
946
  text-align: left;
947
+ min-height: 40px;
693
948
  }
694
949
 
695
950
  .search-result-icon {
696
951
  display: flex;
697
952
  align-items: center;
698
953
  justify-content: center;
699
- min-width: 24px;
954
+ min-width: 28px;
955
+ min-height: 28px;
956
+ font-size: 16px;
700
957
  color: ${iconColor} !important;
701
958
  }
702
959
 
@@ -723,8 +980,9 @@ const SearchBox = {
723
980
 
724
981
  .search-result-title {
725
982
  font-size: 14px;
726
- font-weight: normal;
983
+ font-weight: 500;
727
984
  margin-bottom: 2px;
985
+ line-height: 1.4;
728
986
  }
729
987
 
730
988
  .search-result-subtitle {
@@ -761,6 +1019,64 @@ const SearchBox = {
761
1019
  border-color: ${activeBorder};
762
1020
  color: ${hasThemeColor ? (darkTheme ? lightenHex(themeColor, 0.8) : darkenHex(themeColor, 0.4)) : tagColor};
763
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
+ }
764
1080
  `;
765
1081
  },
766
1082