@underpostnet/underpost 2.96.0 → 2.97.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 (46) hide show
  1. package/.dockerignore +1 -2
  2. package/.env.development +0 -3
  3. package/.env.production +0 -3
  4. package/.env.test +0 -3
  5. package/.prettierignore +1 -2
  6. package/README.md +31 -31
  7. package/baremetal/commission-workflows.json +64 -17
  8. package/baremetal/packer-workflows.json +11 -0
  9. package/cli.md +72 -40
  10. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  11. package/manifests/deployment/dd-test-development/deployment.yaml +4 -4
  12. package/package.json +3 -2
  13. package/packer/images/Rocky9Amd64/rocky9.pkr.hcl +6 -2
  14. package/packer/images/Rocky9Arm64/Makefile +69 -0
  15. package/packer/images/Rocky9Arm64/README.md +122 -0
  16. package/packer/images/Rocky9Arm64/http/rocky9.ks.pkrtpl.hcl +114 -0
  17. package/packer/images/Rocky9Arm64/rocky9.pkr.hcl +171 -0
  18. package/scripts/disk-clean.sh +128 -187
  19. package/scripts/ipxe-setup.sh +197 -0
  20. package/scripts/packer-init-vars-file.sh +16 -6
  21. package/scripts/packer-setup.sh +270 -33
  22. package/scripts/ports-ls.sh +31 -0
  23. package/scripts/quick-tftp.sh +19 -0
  24. package/src/api/document/document.controller.js +15 -0
  25. package/src/api/document/document.model.js +14 -0
  26. package/src/api/document/document.router.js +1 -0
  27. package/src/api/document/document.service.js +61 -3
  28. package/src/cli/baremetal.js +1716 -439
  29. package/src/cli/cloud-init.js +354 -231
  30. package/src/cli/cluster.js +1 -1
  31. package/src/cli/db.js +22 -0
  32. package/src/cli/deploy.js +6 -2
  33. package/src/cli/image.js +1 -0
  34. package/src/cli/index.js +40 -36
  35. package/src/cli/run.js +77 -11
  36. package/src/cli/ssh.js +1 -1
  37. package/src/client/components/core/Input.js +3 -1
  38. package/src/client/components/core/Panel.js +161 -15
  39. package/src/client/components/core/PanelForm.js +198 -35
  40. package/src/client/components/core/Translate.js +11 -0
  41. package/src/client/services/document/document.service.js +19 -0
  42. package/src/index.js +2 -1
  43. package/src/server/dns.js +8 -2
  44. package/src/server/start.js +14 -6
  45. package/manifests/mariadb/config.yaml +0 -10
  46. package/manifests/mariadb/secret.yaml +0 -8
@@ -3,7 +3,7 @@ import { LoadingAnimation } from '../core/LoadingAnimation.js';
3
3
  import { Validator } from '../core/Validator.js';
4
4
  import { Input } from '../core/Input.js';
5
5
  import { darkTheme, ThemeEvents } from './Css.js';
6
- import { append, getDataFromInputFile, htmls, s } from './VanillaJs.js';
6
+ import { append, copyData, getDataFromInputFile, htmls, s } from './VanillaJs.js';
7
7
  import { BtnIcon } from './BtnIcon.js';
8
8
  import { Translate } from './Translate.js';
9
9
  import { DropDown } from './DropDown.js';
@@ -14,6 +14,8 @@ import { RichText } from './RichText.js';
14
14
  import { loggerFactory } from './Logger.js';
15
15
  import { Badge } from './Badge.js';
16
16
  import { Content } from './Content.js';
17
+ import { DocumentService } from '../../services/document/document.service.js';
18
+ import { NotificationManager } from './NotificationManager.js';
17
19
 
18
20
  const logger = loggerFactory(import.meta);
19
21
 
@@ -30,6 +32,9 @@ const Panel = {
30
32
  originData: () => [],
31
33
  filesData: () => [],
32
34
  onClick: () => {},
35
+ share: {
36
+ copyLink: false,
37
+ },
33
38
  },
34
39
  ) {
35
40
  const idPanel = options?.idPanel ? options.idPanel : getId(this.Tokens, `${idPanel}-`);
@@ -87,6 +92,62 @@ const Panel = {
87
92
  htmls(`.${idPanel}-cell-col-a-${id}`, render);
88
93
  },
89
94
  });
95
+ if (options.share && options.share.copyLink) {
96
+ EventsUI.onClick(
97
+ `.${idPanel}-btn-copy-share-${id}`,
98
+ async (e) => {
99
+ try {
100
+ const shareUrl = `${window.location.origin}${window.location.pathname}?cid=${obj._id}`;
101
+ await copyData(shareUrl);
102
+ await NotificationManager.Push({
103
+ status: 'success',
104
+ html: html`<div>${Translate.Render('link-copied')}</div>`,
105
+ });
106
+ // Track the copy share link event
107
+ await DocumentService.patch({ id: obj._id, action: 'copy-share-link' });
108
+ // Update the count in the UI - read current value from span first
109
+ const countSpan = s(`.${idPanel}-share-count-${id}`);
110
+ if (countSpan) {
111
+ const currentCount = parseInt(countSpan.textContent) || 0;
112
+ const newCount = currentCount + 1;
113
+ htmls(`.${idPanel}-share-count-${id}`, newCount);
114
+ } else {
115
+ // Create count badge if it didn't exist before (was 0)
116
+ const btn = s(`.${idPanel}-btn-copy-share-${id}`);
117
+ if (btn) {
118
+ const countBadge = document.createElement('span');
119
+ countBadge.className = `${idPanel}-share-count-${id}`;
120
+ countBadge.style.cssText =
121
+ 'position: absolute; top: -4px; right: -4px; background: #666; color: white; border-radius: 10px; padding: 1px 5px; font-size: 10px; font-weight: bold; min-width: 16px; text-align: center;';
122
+ countBadge.textContent = '1';
123
+ btn.appendChild(countBadge);
124
+ }
125
+ }
126
+ } catch (error) {
127
+ logger.error('Error copying share link:', error);
128
+ await NotificationManager.Push({
129
+ status: 'error',
130
+ html: html`<div>${Translate.Render('error-copying-link')}</div>`,
131
+ });
132
+ }
133
+ },
134
+ { context: 'modal' },
135
+ );
136
+
137
+ // Add tooltip hover effect
138
+ setTimeout(() => {
139
+ const btn = s(`.${idPanel}-btn-copy-share-${id}`);
140
+ const tooltip = s(`.${idPanel}-share-tooltip-${id}`);
141
+ if (btn && tooltip) {
142
+ btn.addEventListener('mouseenter', () => {
143
+ tooltip.style.opacity = '1';
144
+ });
145
+ btn.addEventListener('mouseleave', () => {
146
+ tooltip.style.opacity = '0';
147
+ });
148
+ }
149
+ });
150
+ }
90
151
  EventsUI.onClick(
91
152
  `.${idPanel}-btn-delete-${id}`,
92
153
  async (e) => {
@@ -99,6 +160,8 @@ const Panel = {
99
160
  );
100
161
  EventsUI.onClick(`.${idPanel}-btn-edit-${id}`, async () => {
101
162
  logger.warn('edit', obj);
163
+ const searchId = String(obj._id || obj.id);
164
+
102
165
  if (obj._id) Panel.Tokens[idPanel].editId = obj._id;
103
166
  else if (obj.id) Panel.Tokens[idPanel].editId = obj.id;
104
167
 
@@ -106,14 +169,11 @@ const Panel = {
106
169
  s(`.btn-${idPanel}-label-add`).classList.add('hide');
107
170
 
108
171
  openPanelForm();
109
- // s(`.btn-${idPanel}-add`).click();
110
- s(`.${scrollClassContainer}`).scrollTop = 0;
111
-
172
+ // s(`.${scrollClassContainer}`).scrollTop = 0;
112
173
  const originData = options.originData();
113
174
  const filesData = options.filesData();
114
175
 
115
176
  // Convert IDs to strings for comparison to handle ObjectId vs string issues
116
- const searchId = String(obj._id || obj.id);
117
177
  const foundOrigin = originData.find((d) => String(d._id || d.id) === searchId);
118
178
  const foundFiles = filesData.find((d) => String(d._id || d.id) === searchId);
119
179
 
@@ -133,6 +193,8 @@ const Panel = {
133
193
  );
134
194
  }
135
195
 
196
+ // Clear previous form values then populate with the current item's data
197
+ Input.cleanValues(formData);
136
198
  Input.setValues(formData, obj, foundOrigin, foundFiles);
137
199
  if (options.on.initEdit) await options.on.initEdit({ data: obj });
138
200
  });
@@ -146,28 +208,28 @@ const Panel = {
146
208
  };
147
209
  });
148
210
  if (s(`.${idPanel}-${id}`)) s(`.${idPanel}-${id}`).remove();
149
- return html` <div class="in box-shadow ${idPanel} ${idPanel}-${id}">
211
+ return html` <div class="in box-shadow ${idPanel} ${idPanel}-${id}" style="position: relative;">
150
212
  <div class="fl ${idPanel}-tools session-fl-log-in ${obj.tools ? '' : 'hide'}">
151
213
  ${await BtnIcon.Render({
152
- class: `in flr main-btn-menu action-bar-box ${idPanel}-btn-tool ${idPanel}-btn-edit-${id}`,
153
- label: html`<div class="abs center"><i class="fas fa-edit"></i></div>`,
214
+ class: `in flr main-btn-menu action-bar-box ${idPanel}-btn-tool ${idPanel}-btn-delete-${id}`,
215
+ label: html`<div class="abs center"><i class="fas fa-trash"></i></div>`,
154
216
  useVisibilityHover: true,
155
217
  tooltipHtml: await Badge.Render({
156
218
  id: `tooltip-${idPanel}-${id}`,
157
- text: `${Translate.Render(`edit`)}`,
219
+ text: `${Translate.Render(`delete`)}`,
158
220
  classList: '',
159
- style: { top: `-22px`, left: '-5px' },
221
+ style: { top: `-22px`, left: '-13px' },
160
222
  }),
161
223
  })}
162
224
  ${await BtnIcon.Render({
163
- class: `in flr main-btn-menu action-bar-box ${idPanel}-btn-tool ${idPanel}-btn-delete-${id}`,
164
- label: html`<div class="abs center"><i class="fas fa-trash"></i></div>`,
225
+ class: `in flr main-btn-menu action-bar-box ${idPanel}-btn-tool ${idPanel}-btn-edit-${id}`,
226
+ label: html`<div class="abs center"><i class="fas fa-edit"></i></div>`,
165
227
  useVisibilityHover: true,
166
228
  tooltipHtml: await Badge.Render({
167
229
  id: `tooltip-${idPanel}-${id}`,
168
- text: `${Translate.Render(`delete`)}`,
230
+ text: `${Translate.Render(`edit`)}`,
169
231
  classList: '',
170
- style: { top: `-22px`, left: '-13px' },
232
+ style: { top: `-22px`, left: '-5px' },
171
233
  }),
172
234
  })}
173
235
  </div>
@@ -266,6 +328,32 @@ const Panel = {
266
328
  </div>
267
329
  </div>
268
330
  </div>
331
+ ${options.share && options.share.copyLink
332
+ ? html`<div
333
+ class="${idPanel}-share-btn-container ${idPanel}-share-btn-container-${id}"
334
+ style="position: absolute; bottom: 8px; right: 8px; z-index: 2;"
335
+ >
336
+ <button
337
+ class="btn-icon ${idPanel}-btn-copy-share-${id}"
338
+ style="background: transparent; color: #888; border: none; border-radius: 50%; width: 40px; height: 40px; cursor: pointer; display: flex; align-items: center; justify-content: center; position: relative; transition: all 0.3s ease;"
339
+ >
340
+ <i class="fas fa-link" style="font-size: 20px;"></i>
341
+ ${obj.totalCopyShareLinkCount && obj.totalCopyShareLinkCount > 0
342
+ ? html`<span
343
+ class="${idPanel}-share-count-${id}"
344
+ style="position: absolute; top: -4px; right: -4px; background: #666; color: white; border-radius: 10px; padding: 1px 5px; font-size: 10px; font-weight: bold; min-width: 16px; text-align: center;"
345
+ >${obj.totalCopyShareLinkCount}</span
346
+ >`
347
+ : ''}
348
+ </button>
349
+ <div
350
+ class="${idPanel}-share-tooltip-${id}"
351
+ style="position: absolute; bottom: 50px; right: 0; background: rgba(0,0,0,0.8); color: white; padding: 6px 10px; border-radius: 4px; font-size: 12px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;"
352
+ >
353
+ ${Translate.Render('copy-share-link')}
354
+ </div>
355
+ </div>`
356
+ : ''}
269
357
  </div>`;
270
358
  };
271
359
 
@@ -410,6 +498,11 @@ const Panel = {
410
498
  </div>`,
411
499
  // disabled: true,
412
500
  // disabledEye: true,
501
+ })}
502
+ ${await BtnIcon.Render({
503
+ class: `inl section-mp btn-custom btn-${idPanel}-clean-file`,
504
+ label: html`<i class="fa-solid fa-file-circle-xmark"></i> ${Translate.Render('clear-file')}`,
505
+ type: 'button',
413
506
  })}`;
414
507
  break;
415
508
  default:
@@ -476,6 +569,15 @@ const Panel = {
476
569
  s(`.btn-${idPanel}-clean`).onclick = () => {
477
570
  Input.cleanValues(formData);
478
571
  };
572
+ s(`.btn-${idPanel}-clean-file`).onclick = () => {
573
+ // Clear file input specifically
574
+ const fileFormData = formData.find((f) => f.inputType === 'file');
575
+ if (fileFormData && s(`.${fileFormData.id}`)) {
576
+ s(`.${fileFormData.id}`).value = '';
577
+ s(`.${fileFormData.id}`).inputFiles = null;
578
+ htmls(`.file-name-render-${fileFormData.id}`, `${fileNameInputExtDefaultContent}`);
579
+ }
580
+ };
479
581
  s(`.btn-${idPanel}-close`).onclick = (e) => {
480
582
  e.preventDefault();
481
583
  s(`.${idPanel}-form-body`).style.opacity = 0;
@@ -494,10 +596,26 @@ const Panel = {
494
596
  };
495
597
  s(`.btn-${idPanel}-add`).onclick = async (e) => {
496
598
  e.preventDefault();
497
- // s(`.btn-${idPanel}-clean`).click();
599
+
600
+ // Clean all form inputs and reset data scope
601
+ Input.cleanValues(formData);
602
+
603
+ // Clean file input specifically
604
+ const fileFormData = formData.find((f) => f.inputType === 'file');
605
+ if (fileFormData && s(`.${fileFormData.id}`)) {
606
+ s(`.${fileFormData.id}`).value = '';
607
+ s(`.${fileFormData.id}`).inputFiles = null;
608
+ htmls(`.file-name-render-${fileFormData.id}`, `${fileNameInputExtDefaultContent}`);
609
+ }
610
+
611
+ // Reset edit ID to ensure we're in "add" mode
498
612
  Panel.Tokens[idPanel].editId = undefined;
613
+
614
+ // Update button labels
499
615
  s(`.btn-${idPanel}-label-add`).classList.remove('hide');
500
616
  s(`.btn-${idPanel}-label-edit`).classList.add('hide');
617
+
618
+ // Scroll to top
501
619
  s(`.${scrollClassContainer}`).scrollTop = 0;
502
620
 
503
621
  openPanelForm();
@@ -617,6 +735,34 @@ const Panel = {
617
735
  color: #000000 !important;
618
736
  font-size: 17px !important;
619
737
  }
738
+ .${idPanel}-share-btn-container button:hover {
739
+ background: transparent !important;
740
+ transform: scale(1.1);
741
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4) !important;
742
+ }
743
+ .${idPanel}-share-btn-container button:focus {
744
+ outline: none;
745
+ background: transparent !important;
746
+ }
747
+ .${idPanel}-share-btn-container button:focus {
748
+ outline: none;
749
+ background: transparent !important;
750
+ }
751
+ .${idPanel}-share-btn-container button:active {
752
+ transform: scale(0.95);
753
+ }
754
+ .${idPanel}-share-btn-container span[class*='share-count'] {
755
+ animation: ${idPanel}-share-pulse 2s infinite;
756
+ }
757
+ @keyframes ${idPanel}-share-pulse {
758
+ 0%,
759
+ 100% {
760
+ transform: scale(1);
761
+ }
762
+ 50% {
763
+ transform: scale(1.1);
764
+ }
765
+ }
620
766
  </style>
621
767
  <style class="${idPanel}-styles"></style>
622
768
  <div class="${idPanel}-container">
@@ -27,6 +27,9 @@ const PanelForm = {
27
27
  route: 'home',
28
28
  htmlFormHeader: async () => '',
29
29
  firsUpdateEvent: async () => {},
30
+ share: {
31
+ copyLink: false,
32
+ },
30
33
  },
31
34
  ) {
32
35
  const { idPanel, defaultUrlImage, Elements } = options;
@@ -48,7 +51,7 @@ const PanelForm = {
48
51
  id: 'panel-title',
49
52
  model: 'title',
50
53
  inputType: 'text',
51
- rules: [{ type: 'isEmpty' }],
54
+ rules: [],
52
55
  panel: { type: 'title' },
53
56
  },
54
57
  {
@@ -80,7 +83,7 @@ const PanelForm = {
80
83
  // value: html``,
81
84
  // },
82
85
  // },
83
- rules: [{ type: 'isEmpty' }],
86
+ rules: [],
84
87
  },
85
88
  {
86
89
  id: 'panel-mdFileId',
@@ -108,6 +111,7 @@ const PanelForm = {
108
111
  titleIcon,
109
112
  route: options.route,
110
113
  formContainerClass: 'session-in-log-in',
114
+ share: options.share,
111
115
  onClick: async function ({ payload }) {
112
116
  if (options.route) {
113
117
  setQueryPath({ path: options.route, queryPath: payload._id });
@@ -169,9 +173,113 @@ const PanelForm = {
169
173
  html: status,
170
174
  status,
171
175
  });
172
- if (getQueryParams().cid === data.id) {
173
- setQueryPath({ path: options.route, queryPath: '' });
174
- if (PanelForm.Data[idPanel].updatePanel) await PanelForm.Data[idPanel].updatePanel();
176
+
177
+ // Handle cid query param update (supports comma-separated list)
178
+ if (status === 'success') {
179
+ const currentCid = getQueryParams().cid;
180
+
181
+ if (currentCid) {
182
+ // Parse cid as comma-separated list
183
+ const cidList = currentCid
184
+ .split(',')
185
+ .map((id) => id.trim())
186
+ .filter((id) => id);
187
+
188
+ // Remove the deleted panel's id from the list
189
+ const updatedCidList = cidList.filter((id) => id !== data.id);
190
+
191
+ if (updatedCidList.length !== cidList.length) {
192
+ // Wait for DOM cleanup before updating query
193
+
194
+ if (updatedCidList.length === 0) {
195
+ // No cids remain, clear query and reload panels with limit
196
+ logger.warn('All cids removed, clearing query');
197
+ setQueryPath({ path: options.route, queryPath: '' });
198
+
199
+ if (options.parentIdModal) Modal.Data[options.parentIdModal].query = window.location.search;
200
+ if (PanelForm.Data[idPanel].updatePanel) await PanelForm.Data[idPanel].updatePanel();
201
+ } else {
202
+ // Update query params with remaining cids only (without ?cid= prefix)
203
+ const cidValue = updatedCidList.join(',');
204
+ setQueryPath({ path: options.route, queryPath: cidValue });
205
+ const actualQuery = window.location.search;
206
+ if (options.parentIdModal) Modal.Data[options.parentIdModal].query = actualQuery;
207
+ }
208
+ }
209
+
210
+ // Return early to skip smart deletion logic when cid is present
211
+ return { status };
212
+ }
213
+ }
214
+
215
+ // Smart deletion: remove from arrays and intelligently load more if needed
216
+ if (status === 'success') {
217
+ const panelData = PanelForm.Data[idPanel];
218
+
219
+ // Remove the deleted item from all data arrays
220
+ const indexInOrigin = panelData.originData.findIndex((d) => d._id === data._id);
221
+ const indexInData = panelData.data.findIndex((d) => d._id === data._id);
222
+ const indexInFiles = panelData.filesData.findIndex((d) => d._id === data._id);
223
+
224
+ if (indexInOrigin > -1) panelData.originData.splice(indexInOrigin, 1);
225
+ if (indexInData > -1) panelData.data.splice(indexInData, 1);
226
+ if (indexInFiles > -1) panelData.filesData.splice(indexInFiles, 1);
227
+
228
+ // Adjust skip count since we removed an item
229
+ if (panelData.skip > 0) panelData.skip--;
230
+
231
+ // If panels are below limit and there might be more, load them
232
+ if (panelData.data.length < panelData.limit && panelData.hasMore && !panelData.loading) {
233
+ const oldDataCount = panelData.data.length;
234
+ const needed = panelData.limit - panelData.data.length; // Calculate exact number needed
235
+ const originalLimit = panelData.limit;
236
+
237
+ // Temporarily set limit to only fetch what's needed (1-to-1 replacement)
238
+ panelData.limit = needed;
239
+ await getPanelData(true); // Load only the needed items
240
+ panelData.limit = originalLimit; // Restore original limit
241
+
242
+ const newItems = panelData.data.slice(oldDataCount);
243
+
244
+ if (oldDataCount === 0) {
245
+ // List was empty, render all panels
246
+ if (panelData.data.length > 0) {
247
+ const containerSelector = `.${options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body'}`;
248
+ htmls(
249
+ containerSelector,
250
+ html`
251
+ <div class="in">${await panelRender({ data: panelData.data })}</div>
252
+ <div class="in panel-placeholder-bottom panel-placeholder-bottom-${idPanel}"></div>
253
+ `,
254
+ );
255
+
256
+ // Show spinner if there's potentially more data
257
+ const lastOriginItem = panelData.originData[panelData.originData.length - 1];
258
+ if (
259
+ !panelData.lasIdAvailable ||
260
+ !lastOriginItem ||
261
+ panelData.lasIdAvailable !== lastOriginItem._id
262
+ )
263
+ LoadingAnimation.spinner.play(`.panel-placeholder-bottom-${idPanel}`, 'dual-ring-mini');
264
+ } else {
265
+ // No more data available, show empty state
266
+ const containerSelector = `.${options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body'}`;
267
+ htmls(
268
+ containerSelector,
269
+ html`
270
+ <div class="in">${await panelRender({ data: [] })}</div>
271
+ <div class="in panel-placeholder-bottom panel-placeholder-bottom-${idPanel}"></div>
272
+ `,
273
+ );
274
+ }
275
+ } else {
276
+ // List had some panels, append new ones
277
+ if (newItems.length > 0) {
278
+ for (const item of newItems)
279
+ append(`.${idPanel}-render`, await Panel.Tokens[idPanel].renderPanel(item));
280
+ }
281
+ }
282
+ }
175
283
  }
176
284
 
177
285
  return { status };
@@ -179,30 +287,58 @@ const PanelForm = {
179
287
  return { status: 'error' };
180
288
  },
181
289
  initAdd: async function () {
182
- s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
290
+ setTimeout(() => {
291
+ s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
292
+ }, 50);
183
293
  },
184
294
  initEdit: async function ({ data }) {
185
- s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
295
+ // Clear file input when entering edit mode
296
+ const fileFormData = formData.find((f) => f.inputType === 'file');
297
+ if (fileFormData && s(`.${fileFormData.id}`)) {
298
+ s(`.${fileFormData.id}`).value = '';
299
+ s(`.${fileFormData.id}`).inputFiles = null;
300
+ htmls(
301
+ `.file-name-render-${fileFormData.id}`,
302
+ `<div class="abs center"><i style="font-size: 25px" class="fa-solid fa-cloud"></i></div>`,
303
+ );
304
+ }
305
+ setTimeout(() => {
306
+ s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
307
+ }, 50);
186
308
  },
187
309
  noResultFound: async function () {
188
310
  LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
189
311
  },
190
312
  add: async function ({ data, editId }) {
313
+ // Validate that either mdFileId has content OR fileId has files
314
+ const hasMdContent = data.mdFileId && data.mdFileId.trim().length > 0;
315
+ const hasFiles = data.fileId && data.fileId.length > 0;
316
+
317
+ if (!data.title || (!hasMdContent && !hasFiles)) {
318
+ NotificationManager.Push({
319
+ html: Translate.Render('require-title-and-content-or-file'),
320
+ status: 'error',
321
+ });
322
+ return { data: [], status: 'error', message: 'Must provide either content or attach a file' };
323
+ }
324
+
191
325
  let mdFileId;
192
326
  const mdFileName = `${getCapVariableName(data.title)}.md`;
193
327
  const location = `${prefixTags.join('/')}`;
194
328
  const blob = new Blob([data.mdFileId], { type: 'text/markdown' });
195
329
  const md = new File([blob], mdFileName, { type: 'text/markdown' });
196
- const tags = uniqueArray(
197
- data.tags
198
- .replaceAll('/', ',')
199
- .replaceAll('-', ',')
200
- .replaceAll(' ', ',')
201
- .split(',')
202
- .map((t) => t.trim())
203
- .filter((t) => t)
204
- .concat(prefixTags),
205
- );
330
+ const tags = data.tags
331
+ ? uniqueArray(
332
+ data.tags
333
+ .replaceAll('/', ',')
334
+ .replaceAll('-', ',')
335
+ .replaceAll(' ', ',')
336
+ .split(',')
337
+ .map((t) => t.trim())
338
+ .filter((t) => t)
339
+ .concat(prefixTags),
340
+ )
341
+ : prefixTags;
206
342
  let originObj, originFileObj, indexOriginObj;
207
343
  if (editId) {
208
344
  indexOriginObj = PanelForm.Data[idPanel].originData.findIndex((d) => d._id === editId);
@@ -341,10 +477,16 @@ const PanelForm = {
341
477
  const panelData = PanelForm.Data[idPanel];
342
478
  logger.warn('getPanelData called, isLoadMore:', isLoadMore);
343
479
  try {
344
- if (panelData.loading || !panelData.hasMore) {
345
- logger.warn('getPanelData early return - loading:', panelData.loading, 'hasMore:', panelData.hasMore);
346
- return;
480
+ const cidQuery = getQueryParams().cid;
481
+
482
+ // When cid query exists, bypass pagination and loading checks
483
+ if (!cidQuery) {
484
+ if (panelData.loading || !panelData.hasMore) {
485
+ logger.warn('getPanelData early return - loading:', panelData.loading, 'hasMore:', panelData.hasMore);
486
+ return;
487
+ }
347
488
  }
489
+
348
490
  panelData.loading = true;
349
491
 
350
492
  if (!isLoadMore) {
@@ -353,13 +495,20 @@ const PanelForm = {
353
495
  panelData.hasMore = true;
354
496
  }
355
497
 
498
+ // When cid query exists, don't apply skip/limit pagination
499
+ const params = {
500
+ tags: prefixTags.join(','),
501
+ ...(cidQuery && { cid: cidQuery }),
502
+ };
503
+
504
+ // Only apply pagination when there's no cid query
505
+ if (!cidQuery) {
506
+ params.skip = panelData.skip;
507
+ params.limit = panelData.limit;
508
+ }
509
+
356
510
  const result = await DocumentService.get({
357
- params: {
358
- tags: prefixTags.join(','),
359
- ...(getQueryParams().cid && { cid: getQueryParams().cid }),
360
- skip: panelData.skip,
361
- limit: panelData.limit,
362
- },
511
+ params,
363
512
  id: 'public/',
364
513
  });
365
514
 
@@ -417,6 +566,7 @@ const PanelForm = {
417
566
  fileId,
418
567
  tools: Elements.Data.user.main.model.user._id === documentObject.userId._id,
419
568
  _id: documentObject._id,
569
+ totalCopyShareLinkCount: documentObject.totalCopyShareLinkCount || 0,
420
570
  });
421
571
  } catch (fileError) {
422
572
  logger.error('Error fetching files for document:', documentObject._id, fileError);
@@ -425,9 +575,17 @@ const PanelForm = {
425
575
  }
426
576
  }
427
577
 
428
- panelData.skip += result.data.data.length;
429
- panelData.hasMore = result.data.data.length === panelData.limit;
430
- if (result.data.data.length === 0 || result.data.data.pop()._id === panelData.lasIdAvailable) {
578
+ // Only update pagination when not using cid query
579
+ if (!cidQuery) {
580
+ panelData.skip += result.data.data.length;
581
+ panelData.hasMore = result.data.data.length === panelData.limit;
582
+ } else {
583
+ // When cid query is used, disable infinite scroll
584
+ panelData.hasMore = false;
585
+ }
586
+
587
+ const lastItem = result.data.data[result.data.data.length - 1];
588
+ if (result.data.data.length === 0 || (lastItem && lastItem._id === panelData.lasIdAvailable)) {
431
589
  LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
432
590
  panelData.hasMore = false;
433
591
  }
@@ -538,7 +696,8 @@ const PanelForm = {
538
696
  loading: false,
539
697
  };
540
698
 
541
- if (cid) this.Data[idPanel].skip = 0;
699
+ // Always reset skip to 0 when reloading (whether cid exists or not)
700
+ this.Data[idPanel].skip = 0;
542
701
 
543
702
  const containerSelector = `.${options.parentIdModal ? 'html-' + options.parentIdModal : 'main-body'}`;
544
703
  htmls(containerSelector, await renderSrrPanelData());
@@ -553,22 +712,26 @@ const PanelForm = {
553
712
  `,
554
713
  );
555
714
 
715
+ const lastOriginItem = this.Data[idPanel].originData[this.Data[idPanel].originData.length - 1];
556
716
  if (
557
717
  !this.Data[idPanel].lasIdAvailable ||
558
- this.Data[idPanel].lasIdAvailable !==
559
- this.Data[idPanel].originData[this.Data[idPanel].originData.length - 1]._id
718
+ !lastOriginItem ||
719
+ this.Data[idPanel].lasIdAvailable !== lastOriginItem._id
560
720
  )
561
721
  LoadingAnimation.spinner.play(`.panel-placeholder-bottom-${idPanel}`, 'dual-ring-mini');
562
722
 
563
723
  const scrollContainerSelector = `.modal-${options.route}`;
564
724
 
725
+ // Always remove old scroll event before setting new one
726
+ if (this.Data[idPanel].removeScrollEvent) {
727
+ this.Data[idPanel].removeScrollEvent();
728
+ }
729
+
565
730
  if (cid) {
566
731
  LoadingAnimation.spinner.stop(`.panel-placeholder-bottom-${idPanel}`);
567
732
  return;
568
733
  }
569
- if (this.Data[idPanel].removeScrollEvent) {
570
- this.Data[idPanel].removeScrollEvent();
571
- }
734
+
572
735
  const { removeEvent } = Scroll.setEvent(scrollContainerSelector, async (payload) => {
573
736
  const panelData = PanelForm.Data[idPanel];
574
737
  if (!panelData) return;
@@ -188,6 +188,9 @@ const TranslateCore = {
188
188
  Translate.Data['error-update-user'] = { en: 'error update user', es: 'error al actualizar el usuario' };
189
189
 
190
190
  Translate.Data['edit'] = { en: 'Edit', es: 'Editar' };
191
+ Translate.Data['copy-share-link'] = { en: 'Copy share link', es: 'Copiar enlace compartido' };
192
+ Translate.Data['link-copied'] = { en: 'Link copied to clipboard', es: 'Enlace copiado al portapapeles' };
193
+ Translate.Data['error-copying-link'] = { en: 'Error copying link', es: 'Error al copiar enlace' };
191
194
  Translate.Data['unconfirmed'] = { en: 'unconfirmed', es: 'No confirmado' };
192
195
  Translate.Data['confirmed'] = { en: 'confirmed', es: 'Confirmado' };
193
196
  Translate.Data['confirm'] = { en: 'confirm', es: 'confirmar' };
@@ -520,6 +523,14 @@ const TranslateCore = {
520
523
  en: 'Data reloaded successfully.',
521
524
  es: 'Datos recargados con éxito.',
522
525
  };
526
+ Translate.Data['clear-file'] = {
527
+ en: 'Clear File',
528
+ es: 'Limpiar Archivos',
529
+ };
530
+ Translate.Data['require-title-and-content-or-file'] = {
531
+ en: 'Require title and content or file',
532
+ es: 'Requiere título y contenido o archivo',
533
+ };
523
534
  },
524
535
  };
525
536
 
@@ -92,6 +92,25 @@ const DocumentService = {
92
92
  return reject(error);
93
93
  }),
94
94
  ),
95
+ patch: (options = { id: '', action: '' }) =>
96
+ new Promise((resolve, reject) =>
97
+ fetch(getApiBaseUrl({ id: `${options.id}/${options.action}`, endpoint }), {
98
+ method: 'PATCH',
99
+ headers: headersFactory(),
100
+ credentials: 'include',
101
+ })
102
+ .then(async (res) => {
103
+ return await res.json();
104
+ })
105
+ .then((res) => {
106
+ logger.info(res);
107
+ return resolve(res);
108
+ })
109
+ .catch((error) => {
110
+ logger.error(error);
111
+ return reject(error);
112
+ }),
113
+ ),
95
114
  };
96
115
 
97
116
  export { DocumentService };