@underpostnet/underpost 2.97.1 → 2.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) 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/scripts/rocky-pwa.sh +200 -0
  8. package/src/api/core/core.service.js +0 -5
  9. package/src/api/default/default.service.js +7 -5
  10. package/src/api/document/document.model.js +1 -1
  11. package/src/api/document/document.router.js +5 -0
  12. package/src/api/document/document.service.js +176 -128
  13. package/src/api/file/file.model.js +112 -4
  14. package/src/api/file/file.ref.json +42 -0
  15. package/src/api/file/file.service.js +380 -32
  16. package/src/api/user/user.model.js +38 -1
  17. package/src/api/user/user.router.js +96 -63
  18. package/src/api/user/user.service.js +81 -48
  19. package/src/cli/db.js +424 -166
  20. package/src/cli/index.js +8 -0
  21. package/src/cli/repository.js +1 -1
  22. package/src/cli/run.js +1 -0
  23. package/src/cli/ssh.js +10 -10
  24. package/src/client/components/core/Account.js +327 -36
  25. package/src/client/components/core/AgGrid.js +3 -0
  26. package/src/client/components/core/Auth.js +11 -3
  27. package/src/client/components/core/Chat.js +2 -2
  28. package/src/client/components/core/Content.js +161 -80
  29. package/src/client/components/core/Css.js +30 -0
  30. package/src/client/components/core/CssCore.js +16 -12
  31. package/src/client/components/core/FileExplorer.js +813 -49
  32. package/src/client/components/core/Input.js +207 -12
  33. package/src/client/components/core/LogIn.js +42 -20
  34. package/src/client/components/core/Modal.js +138 -24
  35. package/src/client/components/core/Panel.js +71 -32
  36. package/src/client/components/core/PanelForm.js +262 -77
  37. package/src/client/components/core/PublicProfile.js +888 -0
  38. package/src/client/components/core/Responsive.js +15 -7
  39. package/src/client/components/core/Router.js +117 -15
  40. package/src/client/components/core/SearchBox.js +322 -116
  41. package/src/client/components/core/SignUp.js +26 -7
  42. package/src/client/components/core/SocketIo.js +6 -3
  43. package/src/client/components/core/Translate.js +148 -0
  44. package/src/client/components/core/Validator.js +15 -0
  45. package/src/client/components/core/windowGetDimensions.js +6 -6
  46. package/src/client/components/default/MenuDefault.js +59 -12
  47. package/src/client/components/default/RoutesDefault.js +1 -0
  48. package/src/client/services/core/core.service.js +163 -1
  49. package/src/client/services/default/default.management.js +454 -76
  50. package/src/client/services/default/default.service.js +13 -6
  51. package/src/client/services/file/file.service.js +43 -16
  52. package/src/client/services/user/user.service.js +13 -9
  53. package/src/client/sw/default.sw.js +107 -184
  54. package/src/db/DataBaseProvider.js +1 -1
  55. package/src/db/mongo/MongooseDB.js +1 -1
  56. package/src/index.js +1 -1
  57. package/src/mailer/MailerProvider.js +4 -4
  58. package/src/runtime/express/Express.js +2 -1
  59. package/src/runtime/lampp/Lampp.js +2 -2
  60. package/src/server/auth.js +3 -6
  61. package/src/server/data-query.js +449 -0
  62. package/src/server/object-layer.js +0 -3
  63. package/src/ws/IoInterface.js +2 -2
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Input component module for form controls and file handling utilities.
3
+ * Provides input rendering, file data conversion, and blob endpoint integration.
4
+ *
5
+ * @module src/client/components/core/Input.js
6
+ * @namespace InputClient
7
+ */
8
+
1
9
  import { AgGrid } from './AgGrid.js';
2
10
  import { BtnIcon } from './BtnIcon.js';
3
11
  import { isValidDate } from './CommonJs.js';
@@ -8,8 +16,26 @@ import { RichText } from './RichText.js';
8
16
  import { ToggleSwitch } from './ToggleSwitch.js';
9
17
  import { Translate } from './Translate.js';
10
18
  import { htmls, htmlStrSanitize, s } from './VanillaJs.js';
19
+ import { getApiBaseUrl } from '../../services/core/core.service.js';
20
+ import { FileService } from '../../services/file/file.service.js';
21
+
22
+ /**
23
+ * Logger instance for this module.
24
+ * @type {Function}
25
+ * @memberof InputClient
26
+ * @private
27
+ */
11
28
  const logger = loggerFactory(import.meta);
12
29
 
30
+ /**
31
+ * Creates a FormData object from file input event.
32
+ * Filters files by extension if provided.
33
+ * @function fileFormDataFactory
34
+ * @memberof InputClient
35
+ * @param {Event} e - The input change event containing files.
36
+ * @param {string[]} [extensions] - Optional array of allowed MIME types.
37
+ * @returns {FormData} FormData object containing the valid files.
38
+ */
13
39
  const fileFormDataFactory = (e, extensions) => {
14
40
  const form = new FormData();
15
41
  for (const keyFile of Object.keys(e.target.files)) {
@@ -17,22 +43,163 @@ const fileFormDataFactory = (e, extensions) => {
17
43
  logger.error('Invalid file extension', e.target.files[keyFile]);
18
44
  continue;
19
45
  }
20
- form.append(e.target.files[keyFile].name, e.target.files[keyFile]);
46
+ // form.append(e.target.files[keyFile].name, e.target.files[keyFile]);
47
+ // Use standard 'file' field name for all files - server expects this format
48
+ form.append('file', e.target.files[keyFile]);
21
49
  }
22
50
  return form;
23
51
  };
24
52
 
53
+ /**
54
+ * Convert file data to File object.
55
+ * Supports both legacy format (with buffer data) and new format (metadata only).
56
+ *
57
+ * Legacy format: `{ data: { data: [0, 1, 2, ...] }, mimetype: 'text/markdown', name: 'file.md' }`
58
+ * New format: `{ _id: '...', mimetype: 'text/markdown', name: 'file.md' }`
59
+ *
60
+ * @function getFileFromFileData
61
+ * @memberof InputClient
62
+ * @param {Object} fileData - File data object in legacy or new format.
63
+ * @param {Object} [fileData.data] - Legacy format data container.
64
+ * @param {Array<number>} [fileData.data.data] - Legacy format byte array.
65
+ * @param {string} [fileData._id] - New format file ID.
66
+ * @param {string} fileData.mimetype - MIME type of the file.
67
+ * @param {string} fileData.name - Name of the file.
68
+ * @returns {File|null} File object if legacy format, null if metadata-only or invalid.
69
+ */
25
70
  const getFileFromFileData = (fileData) => {
26
- const blob = new Blob([new Uint8Array(fileData.data.data)], { type: fileData.mimetype });
27
- return new File([blob], fileData.name, { type: fileData.mimetype });
71
+ if (!fileData) {
72
+ logger.error('getFileFromFileData: fileData is undefined');
73
+ return null;
74
+ }
75
+
76
+ // Check if this is legacy format with buffer data
77
+ if (fileData.data?.data) {
78
+ try {
79
+ const blob = new Blob([new Uint8Array(fileData.data.data)], { type: fileData.mimetype });
80
+ return new File([blob], fileData.name, { type: fileData.mimetype });
81
+ } catch (error) {
82
+ logger.error('Error creating File from legacy buffer data:', error);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // New format - metadata only, cannot create File without content
88
+ // Return null and let caller fetch from blob endpoint if needed
89
+ if (fileData._id && !fileData.data?.data) {
90
+ logger.warn(
91
+ 'getFileFromFileData: File is metadata-only, cannot create File object without content. File ID:',
92
+ fileData._id,
93
+ );
94
+ return null;
95
+ }
96
+
97
+ logger.error('getFileFromFileData: Invalid file data structure', fileData);
98
+ return null;
99
+ };
100
+
101
+ /**
102
+ * Fetch file content from blob endpoint and create File object.
103
+ * Used for metadata-only format files during edit mode.
104
+ * Uses FileService with blob/ prefix for centralized blob fetching.
105
+ *
106
+ * @async
107
+ * @function getFileFromBlobEndpoint
108
+ * @memberof InputClient
109
+ * @param {Object} fileData - File metadata object with _id.
110
+ * @param {string} fileData._id - File ID for blob endpoint lookup.
111
+ * @param {string} [fileData.name] - Optional file name.
112
+ * @param {string} [fileData.mimetype] - Optional MIME type.
113
+ * @returns {Promise<File|null>} File object from blob endpoint, or null on error.
114
+ */
115
+ const getFileFromBlobEndpoint = async (fileData) => {
116
+ if (!fileData || !fileData._id) {
117
+ return null;
118
+ }
119
+
120
+ try {
121
+ const { data: blobArray, status } = await FileService.get({ id: `blob/${fileData._id}` });
122
+ if (status !== 'success' || !blobArray || !blobArray[0]) {
123
+ logger.error('Failed to fetch file from blob endpoint');
124
+ return null;
125
+ }
126
+
127
+ const blob = blobArray[0];
128
+ return new File([blob], fileData.name || 'file', { type: fileData.mimetype || blob.type });
129
+ } catch (error) {
130
+ logger.error('Error fetching file from blob endpoint:', error);
131
+ return null;
132
+ }
28
133
  };
29
134
 
135
+ /**
136
+ * Get image/file source URL from file data.
137
+ * Supports both legacy format (with buffer) and new format (metadata only).
138
+ * For new format, returns blob endpoint URL.
139
+ *
140
+ * @function getSrcFromFileData
141
+ * @memberof InputClient
142
+ * @param {Object} fileData - File data object in legacy or new format.
143
+ * @param {Object} [fileData.data] - Legacy format data container.
144
+ * @param {Array<number>} [fileData.data.data] - Legacy format byte array.
145
+ * @param {string} [fileData._id] - New format file ID for blob endpoint.
146
+ * @param {string} fileData.mimetype - MIME type of the file.
147
+ * @returns {string|null} Object URL for legacy format, blob endpoint URL for new format, or null on error.
148
+ */
30
149
  const getSrcFromFileData = (fileData) => {
31
- const file = getFileFromFileData(fileData);
32
- return URL.createObjectURL(file);
150
+ if (!fileData) {
151
+ logger.error('getSrcFromFileData: fileData is undefined');
152
+ return null;
153
+ }
154
+
155
+ // Legacy format with buffer data - create object URL
156
+ if (fileData.data?.data) {
157
+ try {
158
+ const file = getFileFromFileData(fileData);
159
+ if (file) {
160
+ return URL.createObjectURL(file);
161
+ }
162
+ } catch (error) {
163
+ logger.error('Error getting src from legacy buffer data:', error);
164
+ }
165
+ }
166
+
167
+ // New format - use blob endpoint
168
+ if (fileData._id) {
169
+ try {
170
+ return getApiBaseUrl({ id: fileData._id, endpoint: 'file/blob' });
171
+ } catch (error) {
172
+ logger.error('Error generating blob URL:', error);
173
+ return null;
174
+ }
175
+ }
176
+
177
+ logger.error('getSrcFromFileData: Cannot generate src, invalid file data:', fileData);
178
+ return null;
33
179
  };
34
180
 
181
+ /**
182
+ * Input component for rendering various form input types.
183
+ * Supports text, password, file, color, date, dropdown, toggle, rich text, and grid inputs.
184
+ * @namespace InputClient.Input
185
+ * @memberof InputClient
186
+ */
35
187
  const Input = {
188
+ /**
189
+ * Renders an input element based on the provided options.
190
+ * @async
191
+ * @function Render
192
+ * @memberof InputClient.Input
193
+ * @param {Object} options - Input configuration options.
194
+ * @param {string} options.id - Unique identifier for the input.
195
+ * @param {string} [options.type] - Input type (text, password, file, color, datetime-local, etc.).
196
+ * @param {string} [options.placeholder] - Placeholder text.
197
+ * @param {string} [options.label] - Label text for the input.
198
+ * @param {string} [options.containerClass] - CSS class for the container.
199
+ * @param {string} [options.inputClass] - CSS class for the input element.
200
+ * @param {boolean} [options.disabled] - Whether the input is disabled.
201
+ * @returns {Promise<string>} HTML string for the input component.
202
+ */
36
203
  Render: async function (options) {
37
204
  const { id } = options;
38
205
  options?.placeholder
@@ -184,8 +351,8 @@ const Input = {
184
351
  }
185
352
  return obj;
186
353
  },
187
- setValues: function (formData, obj, originObj, fileObj) {
188
- setTimeout(() => {
354
+ setValues: async function (formData, obj, originObj, fileObj) {
355
+ setTimeout(async () => {
189
356
  for (const inputData of formData) {
190
357
  if (!s(`.${inputData.id}`)) continue;
191
358
 
@@ -194,11 +361,31 @@ const Input = {
194
361
  if (fileObj && fileObj[inputData.model] && s(`.${inputData.id}`)) {
195
362
  const dataTransfer = new DataTransfer();
196
363
 
197
- if (fileObj[inputData.model].fileBlob)
198
- dataTransfer.items.add(getFileFromFileData(fileObj[inputData.model].fileBlob));
364
+ if (fileObj[inputData.model].fileBlob) {
365
+ let fileBlobData = getFileFromFileData(fileObj[inputData.model].fileBlob);
366
+
367
+ // If fileBlob is metadata-only, try to fetch from blob endpoint
368
+ if (!fileBlobData && fileObj[inputData.model].fileBlob?._id) {
369
+ fileBlobData = await getFileFromBlobEndpoint(fileObj[inputData.model].fileBlob);
370
+ }
371
+
372
+ if (fileBlobData) {
373
+ dataTransfer.items.add(fileBlobData);
374
+ }
375
+ }
199
376
 
200
- if (fileObj[inputData.model].mdBlob)
201
- dataTransfer.items.add(getFileFromFileData(fileObj[inputData.model].mdBlob));
377
+ if (fileObj[inputData.model].mdBlob) {
378
+ let mdBlobData = getFileFromFileData(fileObj[inputData.model].mdBlob);
379
+
380
+ // If mdBlob is metadata-only, try to fetch from blob endpoint
381
+ if (!mdBlobData && fileObj[inputData.model].mdBlob?._id) {
382
+ mdBlobData = await getFileFromBlobEndpoint(fileObj[inputData.model].mdBlob);
383
+ }
384
+
385
+ if (mdBlobData) {
386
+ dataTransfer.items.add(mdBlobData);
387
+ }
388
+ }
202
389
 
203
390
  if (dataTransfer.files.length) {
204
391
  s(`.${inputData.id}`).files = dataTransfer.files;
@@ -384,4 +571,12 @@ function isTextInputFocused() {
384
571
  return active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA');
385
572
  }
386
573
 
387
- export { Input, InputFile, fileFormDataFactory, getSrcFromFileData, getFileFromFileData, isTextInputFocused };
574
+ export {
575
+ Input,
576
+ InputFile,
577
+ fileFormDataFactory,
578
+ getSrcFromFileData,
579
+ getFileFromFileData,
580
+ getFileFromBlobEndpoint,
581
+ isTextInputFocused,
582
+ };
@@ -1,16 +1,19 @@
1
- import { CoreService } from '../../services/core/core.service.js';
1
+ import { CoreService, getApiBaseUrl } from '../../services/core/core.service.js';
2
2
  import { FileService } from '../../services/file/file.service.js';
3
3
  import { UserService } from '../../services/user/user.service.js';
4
4
  import { Auth } from './Auth.js';
5
5
  import { BtnIcon } from './BtnIcon.js';
6
6
  import { EventsUI } from './EventsUI.js';
7
7
  import { Input } from './Input.js';
8
+ import { loggerFactory } from './Logger.js';
8
9
  import { NotificationManager } from './NotificationManager.js';
9
10
  import { Translate } from './Translate.js';
10
11
  import { Validator } from './Validator.js';
11
12
  import { htmls, s } from './VanillaJs.js';
12
13
  import { Webhook } from './Webhook.js';
13
14
 
15
+ const logger = loggerFactory(import.meta);
16
+
14
17
  const LogIn = {
15
18
  Scope: {
16
19
  user: {
@@ -24,6 +27,8 @@ const LogIn = {
24
27
  Event: {},
25
28
  Trigger: async function (options) {
26
29
  const { user } = options;
30
+ if (user) this.Scope.user.main.model.user = { ...this.Scope.user.main.model.user, ...user };
31
+
27
32
  for (const eventKey of Object.keys(this.Event)) await this.Event[eventKey](options);
28
33
  if (!user || user.role === 'guest') return;
29
34
  await Webhook.register({ user });
@@ -52,26 +57,40 @@ const LogIn = {
52
57
  </style>`,
53
58
  );
54
59
  if (!this.Scope.user.main.model.user.profileImage) {
55
- const resultFile = await FileService.get({ id: user.profileImageId });
56
- if (resultFile && resultFile.status === 'success' && resultFile.data[0]) {
57
- const imageData = resultFile.data[0];
58
-
59
- const imageBlob = new Blob([new Uint8Array(imageData.data.data)], { type: imageData.mimetype });
60
-
61
- const imageFile = new File([imageBlob], imageData.name, { type: imageData.mimetype });
60
+ // Try to load profile image only if profileImageId exists
61
+ if (!this.Scope.user.main.model.user.profileImage && user?.profileImageId) {
62
+ try {
63
+ const resultFile = await FileService.get({ id: user.profileImageId });
64
+ if (resultFile && resultFile.status === 'success' && resultFile.data[0]) {
65
+ const imageData = resultFile.data[0];
66
+ let imageSrc = null;
62
67
 
63
- const imageSrc = URL.createObjectURL(imageFile);
68
+ try {
69
+ // Handle new metadata-only format
70
+ if (!imageData.data?.data && imageData._id) {
71
+ imageSrc = getApiBaseUrl({ id: imageData._id, endpoint: 'file/blob' });
72
+ }
73
+ // Handle legacy format with buffer data
74
+ else if (imageData.data?.data) {
75
+ const imageBlob = new Blob([new Uint8Array(imageData.data.data)], { type: imageData.mimetype });
76
+ const imageFile = new File([imageBlob], imageData.name, { type: imageData.mimetype });
77
+ imageSrc = URL.createObjectURL(imageFile);
78
+ }
64
79
 
65
- // const rawSvg = await CoreService.getRaw({ url: imageSrc });
66
- // rawSvg = rawSvg.replace(`<svg`, `<svg class="abs account-profile-image" `).replace(`#5f5f5f`, `#ffffffc8`);
67
-
68
- this.Scope.user.main.model.user.profileImage = {
69
- resultFile,
70
- imageData,
71
- imageBlob,
72
- imageFile,
73
- imageSrc,
74
- };
80
+ if (imageSrc) {
81
+ this.Scope.user.main.model.user.profileImage = {
82
+ resultFile,
83
+ imageData,
84
+ imageSrc,
85
+ };
86
+ }
87
+ } catch (error) {
88
+ logger.warn('Error processing profile image:', error);
89
+ }
90
+ }
91
+ } catch (error) {
92
+ logger.warn('Error fetching profile image:', error);
93
+ }
75
94
  }
76
95
  htmls(
77
96
  `.action-btn-profile-log-in-render`,
@@ -80,7 +99,10 @@ const LogIn = {
80
99
  class="abs center top-box-profile-img"
81
100
  ${this.Scope.user.main.model.user.profileImage
82
101
  ? `src="${this.Scope.user.main.model.user.profileImage.imageSrc}"`
83
- : ``}
102
+ : `src="${getApiBaseUrl({
103
+ id: 'assets/avatar',
104
+ endpoint: 'user',
105
+ })}"`}
84
106
  />
85
107
  </div>`,
86
108
  );
@@ -74,7 +74,7 @@ const Modal = {
74
74
  const minWidth = width;
75
75
  const heightDefaultTopBar = 50;
76
76
  const heightDefaultBottomBar = 0;
77
- const idModal = options && 'id' in options ? options.id : getId(this.Data, 'modal-');
77
+ const idModal = options.id ? options.id : getId(this.Data, 'modal-');
78
78
  this.Data[idModal] = {
79
79
  options,
80
80
  onCloseListener: {},
@@ -572,7 +572,6 @@ const Modal = {
572
572
  });
573
573
  let currentKeyBoardSearchBoxIndex = 0;
574
574
  let results = [];
575
- let historySearchBox = [];
576
575
 
577
576
  const checkHistoryBoxTitleStatus = () => {
578
577
  if (s(`.search-box-result-title`) && s(`.search-box-result-title`).classList) {
@@ -593,9 +592,28 @@ const Modal = {
593
592
  } else s(`.key-shortcut-container-info`).classList.add('hide');
594
593
  };
595
594
 
596
- const renderSearchResult = async (results) => {
595
+ const renderSearchResult = async (results, isRecentHistory = false) => {
596
+ // Check if the search history modal still exists before rendering
597
+ if (!s(`.html-${searchBoxHistoryId}`)) return;
598
+
597
599
  htmls(`.html-${searchBoxHistoryId}`, '');
598
600
 
601
+ // Show/hide clear-all button based on whether showing recent history
602
+ // Use setTimeout to ensure the button is in the DOM after modal renders
603
+ const updateClearAllBtn = () => {
604
+ const clearAllBtn = s(`.btn-search-history-clear-all`);
605
+ if (clearAllBtn) {
606
+ if (isRecentHistory && results.length > 0) {
607
+ clearAllBtn.style.display = 'flex';
608
+ } else {
609
+ clearAllBtn.style.display = 'none';
610
+ }
611
+ }
612
+ };
613
+ // Try immediately and also with a delay to handle timing
614
+ updateClearAllBtn();
615
+ setTimeout(updateClearAllBtn, 50);
616
+
599
617
  if (results.length === 0) {
600
618
  append(
601
619
  `.html-${searchBoxHistoryId}`,
@@ -621,6 +639,7 @@ const Modal = {
621
639
  const searchContext = {
622
640
  RouterInstance: Worker.RouterInstance,
623
641
  options: options,
642
+ isRecentHistory: isRecentHistory, // Flag for delete button visibility
624
643
  onResultClick: () => {
625
644
  // Dismiss search box on result click
626
645
  if (s(`.${searchBoxHistoryId}`)) {
@@ -662,13 +681,14 @@ const Modal = {
662
681
  const minLength = searchContext.minQueryLength;
663
682
  if (trimmedQuery.length >= minLength) {
664
683
  results = await SearchBox.search(trimmedQuery, searchContext);
665
- renderSearchResult(results);
684
+ renderSearchResult(results, false); // Search results - no delete buttons
666
685
  } else if (trimmedQuery.length === 0) {
667
- // Show history when query is empty
668
- renderSearchResult(historySearchBox);
686
+ // Show recent results from persistent history when query is empty
687
+ const recentResults = SearchBox.RecentResults.getAll();
688
+ renderSearchResult(recentResults, true); // Recent history - show delete buttons
669
689
  } else {
670
690
  // Query is too short - show nothing or a hint
671
- renderSearchResult([]);
691
+ renderSearchResult([], false);
672
692
  }
673
693
  }
674
694
  break;
@@ -698,8 +718,6 @@ const Modal = {
698
718
  });
699
719
  };
700
720
 
701
- const getDefaultSearchBoxSelector = () => `.search-result-btn-${currentKeyBoardSearchBoxIndex}`;
702
-
703
721
  const updateSearchBoxValue = (selector) => {
704
722
  if (!selector) {
705
723
  // Get the currently active search result item
@@ -732,15 +750,18 @@ const Modal = {
732
750
  const resultType = activeItem.getAttribute('data-result-type');
733
751
 
734
752
  if (resultType === 'route' && results[currentKeyBoardSearchBoxIndex]) {
735
- historySearchBox = historySearchBox.filter(
736
- (h) => h.routerId !== results[currentKeyBoardSearchBoxIndex].routerId,
737
- );
738
- historySearchBox.unshift(results[currentKeyBoardSearchBoxIndex]);
753
+ const result = results[currentKeyBoardSearchBoxIndex];
754
+ // Track in persistent history
755
+ SearchBox.RecentResults.add(result);
739
756
  updateSearchBoxValue();
740
757
  if (s(`.main-btn-${resultId}`)) {
741
758
  s(`.main-btn-${resultId}`).click();
742
759
  }
743
760
  } else {
761
+ // Track custom provider result in persistent history
762
+ if (results[currentKeyBoardSearchBoxIndex]) {
763
+ SearchBox.RecentResults.add(results[currentKeyBoardSearchBoxIndex]);
764
+ }
744
765
  // Trigger click on custom result
745
766
  activeItem.click();
746
767
  }
@@ -761,21 +782,28 @@ const Modal = {
761
782
  barConfig.buttons.restore.disabled = true;
762
783
  barConfig.buttons.menu.disabled = true;
763
784
  barConfig.buttons.close.disabled = false;
785
+
764
786
  await Modal.Render({
765
787
  id: searchBoxHistoryId,
766
788
  barConfig,
767
- title: html`<div class="search-box-recent-title">
768
- ${renderViewTitle({
769
- icon: html`<i class="fas fa-history mini-title"></i>`,
770
- text: Translate.Render('recent'),
771
- })}
789
+ title: html`<div
790
+ style="display: flex; align-items: center; justify-content: space-between; width: 100%;"
791
+ >
792
+ <div style="display: flex; align-items: center; gap: 8px;">
793
+ <div class="search-box-recent-title">
794
+ ${renderViewTitle({
795
+ icon: html`<i class="fas fa-history mini-title"></i>`,
796
+ text: Translate.Render('recent'),
797
+ })}
798
+ </div>
799
+ <div class="search-box-result-title hide">
800
+ ${renderViewTitle({
801
+ icon: html`<i class="far fa-list-alt mini-title"></i>`,
802
+ text: Translate.Render('results'),
803
+ })}
804
+ </div>
772
805
  </div>
773
- <div class="search-box-result-title hide">
774
- ${renderViewTitle({
775
- icon: html`<i class="far fa-list-alt mini-title"></i>`,
776
- text: Translate.Render('results'),
777
- })}
778
- </div>`,
806
+ </div>`,
779
807
  html: () => html``,
780
808
  titleClass: 'mini-title',
781
809
  style: {
@@ -787,6 +815,7 @@ const Modal = {
787
815
  : '300px !important',
788
816
  'z-index': 7,
789
817
  },
818
+ class: 'search-history-modal',
790
819
  dragDisabled: true,
791
820
  maximize: true,
792
821
  barMode: options.barMode,
@@ -804,6 +833,63 @@ const Modal = {
804
833
 
805
834
  Modal.MoveTitleToBar(id);
806
835
 
836
+ // Add styles for inline button layout in the search history modal bar
837
+ const styleId = 'search-history-modal-bar-styles';
838
+ if (!s(`#${styleId}`)) {
839
+ const styleTag = document.createElement('style');
840
+ styleTag.id = styleId;
841
+ styleTag.textContent = `
842
+ .search-history-modal .btn-bar-modal-container .bar-default-modal {
843
+ display: flex;
844
+ flex-direction: row-reverse;
845
+ align-items: center;
846
+ }
847
+ .search-history-modal .btn-bar-modal-container .btn-modal-default {
848
+ display: inline-flex;
849
+ align-items: center;
850
+ justify-content: center;
851
+ float: none;
852
+ }
853
+ .search-history-modal .html-${searchBoxHistoryId} {
854
+ overflow-x: hidden;
855
+ box-sizing: border-box;
856
+ }
857
+ .search-history-modal .html-${searchBoxHistoryId} .search-result-item,
858
+ .search-history-modal .html-${searchBoxHistoryId} .search-result-history-item {
859
+ box-sizing: border-box;
860
+ max-width: 100%;
861
+ }
862
+ `;
863
+ document.head.appendChild(styleTag);
864
+ }
865
+
866
+ // Add clear all button to the bar area, before the close button
867
+ const clearAllBtnHtml = await BtnIcon.Render({
868
+ class: `btn-search-history-clear-all btn-modal-default btn-modal-default-${searchBoxHistoryId}`,
869
+ label: html`<i class="fas fa-trash-alt"></i>`,
870
+ attrs: `title="Clear all recent items"`,
871
+ style: 'padding: 4px 8px; font-size: 12px; display: none;',
872
+ });
873
+
874
+ // Insert before close button in the bar (with flex row-reverse, inserting after close button places our button to its left visually)
875
+ const closeBtn = s(`.btn-close-${searchBoxHistoryId}`);
876
+ if (closeBtn) {
877
+ closeBtn.insertAdjacentHTML('afterend', clearAllBtnHtml);
878
+ }
879
+
880
+ // Add click handler for clear all history button
881
+ const clearAllBtn = s(`.btn-search-history-clear-all`);
882
+ if (clearAllBtn) {
883
+ clearAllBtn.onclick = (e) => {
884
+ e.preventDefault();
885
+ e.stopPropagation();
886
+ // Clear all history from persistent storage
887
+ SearchBox.RecentResults.clear();
888
+ // Re-render to show empty history (with isRecentHistory true to hide button)
889
+ renderSearchResult([], true);
890
+ };
891
+ }
892
+
807
893
  prepend(`.btn-bar-modal-container-${id}`, html`<div class="hide">${inputInfoNode.outerHTML}</div>`);
808
894
  }
809
895
  };
@@ -2020,6 +2106,34 @@ const Modal = {
2020
2106
  },
2021
2107
  mobileModal: () => windowGetW() < 600 || windowGetH() < 600,
2022
2108
  writeHTML: ({ idModal, html }) => htmls(`.html-${idModal}`, html),
2109
+ updateModal: async function ({ idModal, html, title }) {
2110
+ if (!this.Data[idModal] || !s(`.${idModal}`)) {
2111
+ console.warn(`Modal ${idModal} not found for update`);
2112
+ return false;
2113
+ }
2114
+
2115
+ // Update modal content
2116
+ if (html) {
2117
+ this.writeHTML({ idModal, html });
2118
+ }
2119
+
2120
+ // Update modal title if provided
2121
+ if (title) {
2122
+ const titleElement = s(`.${idModal} .modal-title`);
2123
+ if (titleElement) {
2124
+ titleElement.innerHTML = title;
2125
+ }
2126
+ }
2127
+
2128
+ // Trigger reload listeners
2129
+ if (this.Data[idModal].onReloadModalListener) {
2130
+ for (const event of Object.keys(this.Data[idModal].onReloadModalListener)) {
2131
+ await this.Data[idModal].onReloadModalListener[event]();
2132
+ }
2133
+ }
2134
+
2135
+ return true;
2136
+ },
2023
2137
  viewModalOpen: function () {
2024
2138
  return Object.keys(this.Data).find((idModal) => s(`.${idModal}`) && this.Data[idModal].options.mode === 'view');
2025
2139
  },