@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
@@ -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)) {
@@ -22,17 +48,156 @@ const fileFormDataFactory = (e, extensions) => {
22
48
  return form;
23
49
  };
24
50
 
51
+ /**
52
+ * Convert file data to File object.
53
+ * Supports both legacy format (with buffer data) and new format (metadata only).
54
+ *
55
+ * Legacy format: `{ data: { data: [0, 1, 2, ...] }, mimetype: 'text/markdown', name: 'file.md' }`
56
+ * New format: `{ _id: '...', mimetype: 'text/markdown', name: 'file.md' }`
57
+ *
58
+ * @function getFileFromFileData
59
+ * @memberof InputClient
60
+ * @param {Object} fileData - File data object in legacy or new format.
61
+ * @param {Object} [fileData.data] - Legacy format data container.
62
+ * @param {Array<number>} [fileData.data.data] - Legacy format byte array.
63
+ * @param {string} [fileData._id] - New format file ID.
64
+ * @param {string} fileData.mimetype - MIME type of the file.
65
+ * @param {string} fileData.name - Name of the file.
66
+ * @returns {File|null} File object if legacy format, null if metadata-only or invalid.
67
+ */
25
68
  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 });
69
+ if (!fileData) {
70
+ logger.error('getFileFromFileData: fileData is undefined');
71
+ return null;
72
+ }
73
+
74
+ // Check if this is legacy format with buffer data
75
+ if (fileData.data?.data) {
76
+ try {
77
+ const blob = new Blob([new Uint8Array(fileData.data.data)], { type: fileData.mimetype });
78
+ return new File([blob], fileData.name, { type: fileData.mimetype });
79
+ } catch (error) {
80
+ logger.error('Error creating File from legacy buffer data:', error);
81
+ return null;
82
+ }
83
+ }
84
+
85
+ // New format - metadata only, cannot create File without content
86
+ // Return null and let caller fetch from blob endpoint if needed
87
+ if (fileData._id && !fileData.data?.data) {
88
+ logger.warn(
89
+ 'getFileFromFileData: File is metadata-only, cannot create File object without content. File ID:',
90
+ fileData._id,
91
+ );
92
+ return null;
93
+ }
94
+
95
+ logger.error('getFileFromFileData: Invalid file data structure', fileData);
96
+ return null;
97
+ };
98
+
99
+ /**
100
+ * Fetch file content from blob endpoint and create File object.
101
+ * Used for metadata-only format files during edit mode.
102
+ * Uses FileService with blob/ prefix for centralized blob fetching.
103
+ *
104
+ * @async
105
+ * @function getFileFromBlobEndpoint
106
+ * @memberof InputClient
107
+ * @param {Object} fileData - File metadata object with _id.
108
+ * @param {string} fileData._id - File ID for blob endpoint lookup.
109
+ * @param {string} [fileData.name] - Optional file name.
110
+ * @param {string} [fileData.mimetype] - Optional MIME type.
111
+ * @returns {Promise<File|null>} File object from blob endpoint, or null on error.
112
+ */
113
+ const getFileFromBlobEndpoint = async (fileData) => {
114
+ if (!fileData || !fileData._id) {
115
+ return null;
116
+ }
117
+
118
+ try {
119
+ const { data: blobArray, status } = await FileService.get({ id: `blob/${fileData._id}` });
120
+ if (status !== 'success' || !blobArray || !blobArray[0]) {
121
+ logger.error('Failed to fetch file from blob endpoint');
122
+ return null;
123
+ }
124
+
125
+ const blob = blobArray[0];
126
+ return new File([blob], fileData.name || 'file', { type: fileData.mimetype || blob.type });
127
+ } catch (error) {
128
+ logger.error('Error fetching file from blob endpoint:', error);
129
+ return null;
130
+ }
28
131
  };
29
132
 
133
+ /**
134
+ * Get image/file source URL from file data.
135
+ * Supports both legacy format (with buffer) and new format (metadata only).
136
+ * For new format, returns blob endpoint URL.
137
+ *
138
+ * @function getSrcFromFileData
139
+ * @memberof InputClient
140
+ * @param {Object} fileData - File data object in legacy or new format.
141
+ * @param {Object} [fileData.data] - Legacy format data container.
142
+ * @param {Array<number>} [fileData.data.data] - Legacy format byte array.
143
+ * @param {string} [fileData._id] - New format file ID for blob endpoint.
144
+ * @param {string} fileData.mimetype - MIME type of the file.
145
+ * @returns {string|null} Object URL for legacy format, blob endpoint URL for new format, or null on error.
146
+ */
30
147
  const getSrcFromFileData = (fileData) => {
31
- const file = getFileFromFileData(fileData);
32
- return URL.createObjectURL(file);
148
+ if (!fileData) {
149
+ logger.error('getSrcFromFileData: fileData is undefined');
150
+ return null;
151
+ }
152
+
153
+ // Legacy format with buffer data - create object URL
154
+ if (fileData.data?.data) {
155
+ try {
156
+ const file = getFileFromFileData(fileData);
157
+ if (file) {
158
+ return URL.createObjectURL(file);
159
+ }
160
+ } catch (error) {
161
+ logger.error('Error getting src from legacy buffer data:', error);
162
+ }
163
+ }
164
+
165
+ // New format - use blob endpoint
166
+ if (fileData._id) {
167
+ try {
168
+ return getApiBaseUrl({ id: fileData._id, endpoint: 'file/blob' });
169
+ } catch (error) {
170
+ logger.error('Error generating blob URL:', error);
171
+ return null;
172
+ }
173
+ }
174
+
175
+ logger.error('getSrcFromFileData: Cannot generate src, invalid file data:', fileData);
176
+ return null;
33
177
  };
34
178
 
179
+ /**
180
+ * Input component for rendering various form input types.
181
+ * Supports text, password, file, color, date, dropdown, toggle, rich text, and grid inputs.
182
+ * @namespace InputClient.Input
183
+ * @memberof InputClient
184
+ */
35
185
  const Input = {
186
+ /**
187
+ * Renders an input element based on the provided options.
188
+ * @async
189
+ * @function Render
190
+ * @memberof InputClient.Input
191
+ * @param {Object} options - Input configuration options.
192
+ * @param {string} options.id - Unique identifier for the input.
193
+ * @param {string} [options.type] - Input type (text, password, file, color, datetime-local, etc.).
194
+ * @param {string} [options.placeholder] - Placeholder text.
195
+ * @param {string} [options.label] - Label text for the input.
196
+ * @param {string} [options.containerClass] - CSS class for the container.
197
+ * @param {string} [options.inputClass] - CSS class for the input element.
198
+ * @param {boolean} [options.disabled] - Whether the input is disabled.
199
+ * @returns {Promise<string>} HTML string for the input component.
200
+ */
36
201
  Render: async function (options) {
37
202
  const { id } = options;
38
203
  options?.placeholder
@@ -184,8 +349,8 @@ const Input = {
184
349
  }
185
350
  return obj;
186
351
  },
187
- setValues: function (formData, obj, originObj, fileObj) {
188
- setTimeout(() => {
352
+ setValues: async function (formData, obj, originObj, fileObj) {
353
+ setTimeout(async () => {
189
354
  for (const inputData of formData) {
190
355
  if (!s(`.${inputData.id}`)) continue;
191
356
 
@@ -194,11 +359,31 @@ const Input = {
194
359
  if (fileObj && fileObj[inputData.model] && s(`.${inputData.id}`)) {
195
360
  const dataTransfer = new DataTransfer();
196
361
 
197
- if (fileObj[inputData.model].fileBlob)
198
- dataTransfer.items.add(getFileFromFileData(fileObj[inputData.model].fileBlob));
362
+ if (fileObj[inputData.model].fileBlob) {
363
+ let fileBlobData = getFileFromFileData(fileObj[inputData.model].fileBlob);
364
+
365
+ // If fileBlob is metadata-only, try to fetch from blob endpoint
366
+ if (!fileBlobData && fileObj[inputData.model].fileBlob?._id) {
367
+ fileBlobData = await getFileFromBlobEndpoint(fileObj[inputData.model].fileBlob);
368
+ }
369
+
370
+ if (fileBlobData) {
371
+ dataTransfer.items.add(fileBlobData);
372
+ }
373
+ }
199
374
 
200
- if (fileObj[inputData.model].mdBlob)
201
- dataTransfer.items.add(getFileFromFileData(fileObj[inputData.model].mdBlob));
375
+ if (fileObj[inputData.model].mdBlob) {
376
+ let mdBlobData = getFileFromFileData(fileObj[inputData.model].mdBlob);
377
+
378
+ // If mdBlob is metadata-only, try to fetch from blob endpoint
379
+ if (!mdBlobData && fileObj[inputData.model].mdBlob?._id) {
380
+ mdBlobData = await getFileFromBlobEndpoint(fileObj[inputData.model].mdBlob);
381
+ }
382
+
383
+ if (mdBlobData) {
384
+ dataTransfer.items.add(mdBlobData);
385
+ }
386
+ }
202
387
 
203
388
  if (dataTransfer.files.length) {
204
389
  s(`.${inputData.id}`).files = dataTransfer.files;
@@ -384,4 +569,12 @@ function isTextInputFocused() {
384
569
  return active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA');
385
570
  }
386
571
 
387
- export { Input, InputFile, fileFormDataFactory, getSrcFromFileData, getFileFromFileData, isTextInputFocused };
572
+ export {
573
+ Input,
574
+ InputFile,
575
+ fileFormDataFactory,
576
+ getSrcFromFileData,
577
+ getFileFromFileData,
578
+ getFileFromBlobEndpoint,
579
+ isTextInputFocused,
580
+ };
@@ -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
  },