underpost 3.2.9 → 3.2.11

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 (104) hide show
  1. package/.github/workflows/npmpkg.ci.yml +1 -0
  2. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  3. package/.github/workflows/release.cd.yml +1 -0
  4. package/.vscode/extensions.json +9 -9
  5. package/.vscode/settings.json +20 -4
  6. package/CHANGELOG.md +195 -1
  7. package/CLI-HELP.md +92 -23
  8. package/README.md +38 -9
  9. package/bin/build.js +27 -7
  10. package/bin/build.template.js +187 -0
  11. package/bin/deploy.js +12 -2
  12. package/bin/index.js +2 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -7
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
  19. package/manifests/kind-config-dev.yaml +8 -0
  20. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  21. package/manifests/mongodb/pv-pvc.yaml +44 -8
  22. package/manifests/mongodb/statefulset.yaml +55 -68
  23. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  24. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  25. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  26. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  27. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  28. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  29. package/manifests/valkey/statefulset.yaml +1 -1
  30. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  31. package/package.json +27 -12
  32. package/scripts/ipxe-setup.sh +52 -49
  33. package/scripts/k3s-node-setup.sh +81 -46
  34. package/scripts/lxd-vm-setup.sh +193 -8
  35. package/scripts/maas-nat-firewalld.sh +145 -0
  36. package/src/api/core/core.router.js +19 -14
  37. package/src/api/core/core.service.js +5 -5
  38. package/src/api/default/default.router.js +22 -18
  39. package/src/api/default/default.service.js +5 -5
  40. package/src/api/document/document.router.js +28 -23
  41. package/src/api/document/document.service.js +100 -23
  42. package/src/api/file/file.router.js +19 -13
  43. package/src/api/file/file.service.js +9 -7
  44. package/src/api/test/test.router.js +17 -12
  45. package/src/api/types.js +24 -0
  46. package/src/api/user/guest.service.js +5 -4
  47. package/src/api/user/user.router.js +297 -288
  48. package/src/api/user/user.service.js +100 -35
  49. package/src/cli/baremetal.js +132 -101
  50. package/src/cli/cluster.js +700 -232
  51. package/src/cli/db.js +59 -60
  52. package/src/cli/deploy.js +216 -137
  53. package/src/cli/fs.js +13 -3
  54. package/src/cli/index.js +80 -15
  55. package/src/cli/ipfs.js +4 -6
  56. package/src/cli/kubectl.js +4 -1
  57. package/src/cli/lxd.js +1099 -223
  58. package/src/cli/monitor.js +9 -3
  59. package/src/cli/release.js +334 -140
  60. package/src/cli/repository.js +68 -23
  61. package/src/cli/run.js +191 -47
  62. package/src/cli/secrets.js +11 -2
  63. package/src/cli/test.js +9 -3
  64. package/src/client/Default.index.js +9 -3
  65. package/src/client/components/core/Auth.js +5 -0
  66. package/src/client/components/core/ClientEvents.js +76 -0
  67. package/src/client/components/core/EventBus.js +4 -0
  68. package/src/client/components/core/Modal.js +82 -41
  69. package/src/client/components/core/PanelForm.js +56 -52
  70. package/src/client/components/core/Worker.js +162 -363
  71. package/src/client/sw/core.sw.js +174 -112
  72. package/src/db/DataBaseProvider.js +115 -15
  73. package/src/db/mariadb/MariaDB.js +2 -1
  74. package/src/db/mongo/MongoBootstrap.js +657 -0
  75. package/src/db/mongo/MongooseDB.js +129 -21
  76. package/src/index.js +1 -1
  77. package/src/runtime/express/Express.js +2 -2
  78. package/src/runtime/wp/Wp.js +8 -5
  79. package/src/server/auth.js +2 -2
  80. package/src/server/client-build-docs.js +1 -1
  81. package/src/server/client-build.js +94 -129
  82. package/src/server/conf.js +81 -79
  83. package/src/server/process.js +180 -19
  84. package/src/server/proxy.js +9 -2
  85. package/src/server/runtime.js +1 -1
  86. package/src/server/start.js +16 -4
  87. package/src/server/valkey.js +2 -0
  88. package/src/ws/IoInterface.js +16 -16
  89. package/src/ws/core/channels/core.ws.chat.js +11 -11
  90. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  91. package/src/ws/core/channels/core.ws.stream.js +19 -19
  92. package/src/ws/core/core.ws.connection.js +8 -8
  93. package/src/ws/core/core.ws.server.js +6 -5
  94. package/src/ws/default/channels/default.ws.main.js +10 -10
  95. package/src/ws/default/default.ws.connection.js +4 -4
  96. package/src/ws/default/default.ws.server.js +4 -3
  97. package/bin/file.js +0 -202
  98. package/bin/vs.js +0 -74
  99. package/bin/zed.js +0 -84
  100. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  101. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  102. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  103. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  104. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -26,17 +26,26 @@ class UnderpostSecret {
26
26
  * @memberof UnderpostSecret
27
27
  */
28
28
  underpost: {
29
- createFromEnvFile(envPath) {
29
+ /**
30
+ * @method createFromEnvFile
31
+ * @description Reads application secrets from a .env file and writes them to the underpost .env file. Used for local development and testing.
32
+ * @param {string} envPath - The path to the .env file to read secrets from. Defaults to './.env'.
33
+ * @memberof UnderpostSecret
34
+ */
35
+ createFromEnvFile(envPath = './.env') {
30
36
  Underpost.env.clean();
31
37
  const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
32
38
  for (const key of Object.keys(envObj)) {
33
39
  Underpost.env.set(key, envObj[key]);
34
40
  }
35
41
  },
36
- /** Reads application secrets from process.env (injected via envFrom: secretRef)
42
+ /**
43
+ * @method createFromContainerEnv
44
+ * @description Reads application secrets from process.env (injected via envFrom: secretRef)
37
45
  * and writes them to the underpost .env file, filtering out known system and
38
46
  * Kubernetes-injected environment variables. Replaces the fragile shell-based
39
47
  * `printenv | grep -vE` pattern with a maintainable Node.js blocklist.
48
+ * @memberof UnderpostSecret
40
49
  */
41
50
  createFromContainerEnv() {
42
51
  Underpost.env.clean();
package/src/cli/test.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * @namespace UnderpostTest
5
5
  */
6
6
 
7
+ import fs from 'fs-extra';
7
8
  import { timer } from '../client/components/core/CommonJs.js';
8
9
  import { MariaDB } from '../db/mariadb/MariaDB.js';
9
10
  import { getNpmRootPath } from '../server/conf.js';
@@ -42,7 +43,13 @@ class UnderpostTest {
42
43
  */
43
44
  run() {
44
45
  actionInitLog();
45
- shellExec(`cd ${getNpmRootPath()}/underpost && npm run test`);
46
+ const underpostTestPath = `${getNpmRootPath()}/underpost`;
47
+ if (fs.existsSync(underpostTestPath)) {
48
+ shellExec(`cd ${underpostTestPath} && npm run test`);
49
+ } else {
50
+ logger.warn(`Global underpost not found at ${underpostTestPath}, running local npm test instead`);
51
+ shellExec('npm test');
52
+ }
46
53
  },
47
54
  /**
48
55
  * @method callback
@@ -148,8 +155,7 @@ class UnderpostTest {
148
155
  const pods = Underpost.kubectl.get(podName, kindType);
149
156
  let result = pods.find((p) => p.STATUS === status || (status === 'Running' && p.STATUS === 'Completed'));
150
157
  logger.info(
151
- `Testing pod ${podName}... ${result ? 1 : 0}/1 - elapsed time ${deltaMs * (index + 1)}s - attempt ${
152
- index + 1
158
+ `Testing pod ${podName}... ${result ? 1 : 0}/1 - elapsed time ${deltaMs * (index + 1)}s - attempt ${index + 1
153
159
  }/${maxAttempts}`,
154
160
  pods[0] ? pods[0].STATUS : 'Not found kind object',
155
161
  );
@@ -19,6 +19,12 @@ const DefaultTemplate = async () => {
19
19
  });
20
20
  });
21
21
  return html`
22
+ <style>
23
+ .feature-icon {
24
+ font-size: 2.5rem;
25
+ margin-bottom: 1rem;
26
+ }
27
+ </style>
22
28
  <div class="landing-container">
23
29
  <div class="content-wrapper">
24
30
  <h1 class="animated-text">
@@ -27,17 +33,17 @@ const DefaultTemplate = async () => {
27
33
  </h1>
28
34
  <div class="features">
29
35
  <div class="feature-card">
30
- <i class="icon">🚀</i>
36
+ <i class="fas fa-rocket feature-icon"></i>
31
37
  <h3>Fast &amp; Reliable</h3>
32
38
  <p>Lightning-fast performance with 99.9% uptime</p>
33
39
  </div>
34
40
  <div class="feature-card">
35
- <i class="icon">🎨</i>
41
+ <i class="fas fa-palette feature-icon"></i>
36
42
  <h3>Beautiful UI</h3>
37
43
  <p>Modern and intuitive user interface</p>
38
44
  </div>
39
45
  <div class="feature-card">
40
- <i class="icon">⚡</i>
46
+ <i class="fas fa-bolt feature-icon"></i>
41
47
  <h3>Powerful Features</h3>
42
48
  <p>Everything you need in one place</p>
43
49
  </div>
@@ -319,6 +319,11 @@ class Auth {
319
319
  // Close any open login/signup modals
320
320
  if (s(`.modal-log-in`)) s(`.btn-close-modal-log-in`).click();
321
321
  if (s(`.modal-sign-up`)) s(`.btn-close-modal-sign-up`).click();
322
+ if (!s(`.main-body-btn-ui-open`).classList.contains('hide')) s(`.main-body-btn-ui-open`).click();
323
+ if (!s(`.main-body-btn-ui-bar-custom-open`).classList.contains('hide')) {
324
+ SearchBox.Data.skipOpen = true;
325
+ s(`.main-body-btn-ui-bar-custom-open`).click();
326
+ }
322
327
  });
323
328
  }
324
329
 
@@ -45,6 +45,79 @@ const AppointmentEventType = {
45
45
  submitted: 'appointment:submitted',
46
46
  };
47
47
 
48
+ const ModalEventType = {
49
+ close: 'modal:close',
50
+ menu: 'modal:menu',
51
+ collapseMenu: 'modal:collapse-menu',
52
+ extendMenu: 'modal:extend-menu',
53
+ dragEnd: 'modal:drag-end',
54
+ observer: 'modal:observer',
55
+ click: 'modal:click',
56
+ expandUi: 'modal:expand-ui',
57
+ barUiOpen: 'modal:bar-ui-open',
58
+ barUiClose: 'modal:bar-ui-close',
59
+ reload: 'modal:reload',
60
+ home: 'modal:home',
61
+ };
62
+
63
+ const ModalListenerChannels = {
64
+ onCloseListener: ModalEventType.close,
65
+ onMenuListener: ModalEventType.menu,
66
+ onCollapseMenuListener: ModalEventType.collapseMenu,
67
+ onExtendMenuListener: ModalEventType.extendMenu,
68
+ onDragEndListener: ModalEventType.dragEnd,
69
+ onObserverListener: ModalEventType.observer,
70
+ onClickListener: ModalEventType.click,
71
+ onExpandUiListener: ModalEventType.expandUi,
72
+ onBarUiOpen: ModalEventType.barUiOpen,
73
+ onBarUiClose: ModalEventType.barUiClose,
74
+ onReloadModalListener: ModalEventType.reload,
75
+ onHome: ModalEventType.home,
76
+ };
77
+
78
+ const createModalEventChannel = (bus, type) => {
79
+ const busKey = (key) => `${type}::${key}`;
80
+ return new Proxy(
81
+ {},
82
+ {
83
+ get(_target, prop) {
84
+ if (typeof prop === 'symbol') return undefined;
85
+ const key = busKey(prop);
86
+ if (!bus.has(key)) return undefined;
87
+ return (detail) => bus.emitKey(key, detail);
88
+ },
89
+ set(_target, prop, value) {
90
+ if (typeof prop !== 'symbol' && typeof value === 'function') bus.on(type, value, { key: busKey(prop) });
91
+ return true;
92
+ },
93
+ deleteProperty(_target, prop) {
94
+ if (typeof prop !== 'symbol') bus.off(busKey(prop));
95
+ return true;
96
+ },
97
+ has(_target, prop) {
98
+ return typeof prop !== 'symbol' && bus.has(busKey(prop));
99
+ },
100
+ ownKeys() {
101
+ const prefix = `${type}::`;
102
+ return bus.keysOf(type).map((key) => String(key).slice(prefix.length));
103
+ },
104
+ getOwnPropertyDescriptor(_target, prop) {
105
+ if (typeof prop !== 'symbol' && bus.has(busKey(prop)))
106
+ return { enumerable: true, configurable: true, writable: true, value: undefined };
107
+ return undefined;
108
+ },
109
+ },
110
+ );
111
+ };
112
+
113
+ // One EventBus per modal id, surfaced through the legacy channel names.
114
+ const createModalEvents = () => {
115
+ const bus = new EventBus();
116
+ const channels = {};
117
+ for (const [name, type] of Object.entries(ModalListenerChannels)) channels[name] = createModalEventChannel(bus, type);
118
+ return { bus, channels };
119
+ };
120
+
48
121
  const authLoginEvents = new EventBus();
49
122
  const authLogoutEvents = new EventBus();
50
123
  const authSignupEvents = new EventBus();
@@ -70,6 +143,9 @@ export {
70
143
  KeyboardEventType,
71
144
  AccountEventType,
72
145
  AppointmentEventType,
146
+ ModalEventType,
147
+ ModalListenerChannels,
148
+ createModalEvents,
73
149
  authLoginEvents,
74
150
  authLogoutEvents,
75
151
  authSignupEvents,
@@ -69,6 +69,10 @@ class EventBus {
69
69
  return this.listeners.has(key);
70
70
  }
71
71
 
72
+ keysOf(type) {
73
+ return [...(this.typeKeys.get(type) ?? [])];
74
+ }
75
+
72
76
  async emit(type, detail) {
73
77
  if (!(this.typeKeys.get(type)?.size > 0)) return;
74
78
 
@@ -36,6 +36,7 @@ import { Worker } from './Worker.js';
36
36
  import { Scroll } from './Scroll.js';
37
37
  import { windowGetH, windowGetW } from './windowGetDimensions.js';
38
38
  import { SearchBox } from './SearchBox.js';
39
+ import { createModalEvents } from './ClientEvents.js';
39
40
 
40
41
  const logger = loggerFactory(import.meta, { trace: true });
41
42
 
@@ -93,6 +94,11 @@ const logger = loggerFactory(import.meta, { trace: true });
93
94
  /**
94
95
  * @typedef {object} ModalDataEntry
95
96
  * @property {ModalRenderOptions} options - Original render options.
97
+ * @property {import('./EventBus.js').EventBus} events - Per-modal event bus backing the listener channels below.
98
+ *
99
+ * The `onX` channels are EventBus-backed proxies that preserve the historical
100
+ * `{ [key]: listener }` map API: assign to register, read+call to invoke a single
101
+ * listener, `delete` to remove, and `Object.keys` to enumerate registered keys.
96
102
  * @property {Object.<string, Function>} onCloseListener - Close event listeners keyed by id.
97
103
  * @property {Object.<string, Function>} onMenuListener - Menu button event listeners.
98
104
  * @property {Object.<string, Function>} onCollapseMenuListener - Collapse menu listeners.
@@ -123,6 +129,69 @@ class Modal {
123
129
  /** @type {Object.<string, ModalDataEntry>} */
124
130
  static Data = {};
125
131
 
132
+ /**
133
+ * Bottom offset of the slide-menu area for the given options.
134
+ * @param {ModalRenderOptions} options
135
+ * @param {number} [heightDefaultBottomBar=0]
136
+ * @returns {number}
137
+ */
138
+ static getModalTop(options, heightDefaultBottomBar = 0) {
139
+ return windowGetH() - (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar);
140
+ }
141
+
142
+ /**
143
+ * Available modal height, accounting for the top/bottom bars when the UI is expanded.
144
+ * @param {ModalRenderOptions} options
145
+ * @param {number} [heightDefaultTopBar=50]
146
+ * @param {number} [heightDefaultBottomBar=0]
147
+ * @returns {number}
148
+ */
149
+ static getModalHeight(options, heightDefaultTopBar = 50, heightDefaultBottomBar = 0) {
150
+ const barsVisible = s(`.main-body-btn-ui-close`) && !s(`.main-body-btn-ui-close`).classList.contains('hide');
151
+ return (
152
+ windowGetH() -
153
+ (barsVisible
154
+ ? (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) +
155
+ (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
156
+ : 0)
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Left CSS value for a slide menu in its open/closed state.
162
+ * @param {ModalRenderOptions} options
163
+ * @param {{ originSlideMenuWidth: number, collapseSlideMenuWidth: number }} widths
164
+ * @param {{ open: boolean }} [ops={ open: false }]
165
+ * @returns {string}
166
+ */
167
+ static getModalMenuLeftStyle(options, { originSlideMenuWidth, collapseSlideMenuWidth }, ops = { open: false }) {
168
+ return `${
169
+ options.mode === 'slide-menu-right'
170
+ ? `${
171
+ windowGetW() +
172
+ (ops?.open
173
+ ? -1 * originSlideMenuWidth +
174
+ (options.mode === 'slide-menu-right' && s(`.btn-icon-menu-mode-right`).classList.contains('hide')
175
+ ? originSlideMenuWidth - collapseSlideMenuWidth
176
+ : 0)
177
+ : originSlideMenuWidth)
178
+ }px`
179
+ : `-${ops?.open ? '0px' : originSlideMenuWidth}px`
180
+ }`;
181
+ }
182
+
183
+ /**
184
+ * Viewport-centered top/left CSS values for a modal of the given size.
185
+ * @param {{ width: number, height: number }} param0
186
+ * @returns {{ top: string, left: string }}
187
+ */
188
+ static getModalCenter({ width, height }) {
189
+ return {
190
+ top: `${windowGetH() / 2 - height / 2}px`,
191
+ left: `${windowGetW() / 2 - width / 2}px`,
192
+ };
193
+ }
194
+
126
195
  /**
127
196
  * Create or reload a modal. When the modal already exists in the DOM the
128
197
  * existing instance is reloaded via its onReloadModalListener callbacks.
@@ -175,52 +244,21 @@ class Modal {
175
244
  const heightDefaultTopBar = 50;
176
245
  const heightDefaultBottomBar = 0;
177
246
  const idModal = options.id ? options.id : getId(this.Data, 'modal-');
247
+ const { bus: eventBus, channels: eventChannels } = createModalEvents();
178
248
  this.Data[idModal] = {
179
249
  options,
180
- onCloseListener: {},
181
- onMenuListener: {},
182
- onCollapseMenuListener: {},
183
- onExtendMenuListener: {},
184
- onDragEndListener: {},
185
- onObserverListener: {},
186
- onClickListener: {},
187
- onExpandUiListener: {},
188
- onBarUiOpen: {},
189
- onBarUiClose: {},
190
- onReloadModalListener: {},
191
- onHome: {},
250
+ events: eventBus,
251
+ ...eventChannels,
192
252
  homeModals: options.homeModals ? options.homeModals : [],
193
253
  query: options.query ? `${window.location.search}` : undefined,
194
- getTop: () => {
195
- const result = windowGetH() - (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar);
196
- return result;
197
- },
198
- getHeight: () => {
199
- return (
200
- windowGetH() -
201
- (s(`.main-body-btn-ui-close`) && !s(`.main-body-btn-ui-close`).classList.contains('hide')
202
- ? (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) +
203
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
204
- : 0)
205
- );
206
- },
254
+ getTop: () => Modal.getModalTop(options, heightDefaultBottomBar),
255
+ getHeight: () => Modal.getModalHeight(options, heightDefaultTopBar, heightDefaultBottomBar),
207
256
  getMenuLeftStyle: (ops = { open: false }) =>
208
- `${
209
- options.mode === 'slide-menu-right'
210
- ? `${
211
- windowGetW() +
212
- (ops?.open
213
- ? -1 * originSlideMenuWidth +
214
- (options.mode === 'slide-menu-right' && s(`.btn-icon-menu-mode-right`).classList.contains('hide')
215
- ? originSlideMenuWidth - collapseSlideMenuWidth
216
- : 0)
217
- : originSlideMenuWidth)
218
- }px`
219
- : `-${ops?.open ? '0px' : originSlideMenuWidth}px`
220
- }`,
257
+ Modal.getModalMenuLeftStyle(options, { originSlideMenuWidth, collapseSlideMenuWidth }, ops),
221
258
  center: () => {
222
- top = `${windowGetH() / 2 - height / 2}px`;
223
- left = `${windowGetW() / 2 - width / 2}px`;
259
+ const { top: centeredTop, left: centeredLeft } = Modal.getModalCenter({ width, height });
260
+ top = centeredTop;
261
+ left = centeredLeft;
224
262
  },
225
263
  ...this.Data[idModal],
226
264
  };
@@ -1029,6 +1067,10 @@ class Modal {
1029
1067
  hoverFocusCtl.checkDismiss();
1030
1068
  };
1031
1069
  EventsUI.onClick(`.top-bar-search-box-container`, () => {
1070
+ if (SearchBox.Data.skipOpen) {
1071
+ SearchBox.Data.skipOpen = false;
1072
+ return;
1073
+ }
1032
1074
  searchBoxHistoryOpen();
1033
1075
  searchBoxCallBack(formDataInfoNode[0]);
1034
1076
  const inputEl = s(`.${inputSearchBoxId}`);
@@ -2138,7 +2180,6 @@ class Modal {
2138
2180
  dragInstance = setDragInstance();
2139
2181
  if (options && options.maximize) s(`.btn-maximize-${idModal}`).click();
2140
2182
  if (options.observer) {
2141
- this.Data[idModal].onObserverListener = {};
2142
2183
  this.Data[idModal].observerCallBack = () => {
2143
2184
  // logger.info('ResizeObserver', `.${idModal}`, s(`.${idModal}`).offsetWidth, s(`.${idModal}`).offsetHeight);
2144
2185
  if (this.Data[idModal] && this.Data[idModal].onObserverListener)
@@ -73,7 +73,7 @@ class PanelForm {
73
73
  parentIdModal: undefined,
74
74
  route: 'home',
75
75
  htmlFormHeader: async () => '',
76
- firsUpdateEvent: async () => {},
76
+ firsUpdateEvent: async () => { },
77
77
  share: {
78
78
  copyLink: false,
79
79
  copySourceMd: false,
@@ -196,12 +196,12 @@ class PanelForm {
196
196
  <img
197
197
  class="abs center"
198
198
  style="${renderCssAttr({
199
- style: {
200
- width: '100px',
201
- height: '100px',
202
- opacity: 0.2,
203
- },
204
- })}"
199
+ style: {
200
+ width: '100px',
201
+ height: '100px',
202
+ opacity: 0.2,
203
+ },
204
+ })}"
205
205
  src="${defaultUrlImage}"
206
206
  />
207
207
  `,
@@ -332,16 +332,10 @@ class PanelForm {
332
332
  }, 50);
333
333
  },
334
334
  initEdit: async function ({ data }) {
335
- // Clear file input when entering edit mode
336
- const fileFormData = formData.find((f) => f.inputType === 'file');
337
- if (fileFormData && s(`.${fileFormData.id}`)) {
338
- s(`.${fileFormData.id}`).value = '';
339
- s(`.${fileFormData.id}`).inputFiles = null;
340
- htmls(
341
- `.file-name-render-${fileFormData.id}`,
342
- `<div class="abs center"><i style="font-size: 25px" class="fa-solid fa-cloud"></i></div>`,
343
- );
344
- }
335
+ // Do NOT clear the file input here - the file should remain as-is when entering edit mode.
336
+ // If user wants to remove the file, they use the "clean file" button.
337
+ // If user wants to replace the file, they select a new file.
338
+ // Unconditionally clearing the file here would cause the server to receive fileId: null on save.
345
339
  setTimeout(() => {
346
340
  s(`.modal-${options.route}`).scrollTo({ top: 0, behavior: 'smooth' });
347
341
  }, 50);
@@ -388,15 +382,15 @@ class PanelForm {
388
382
  // It will be filtered from the tags array to keep visibility control separate from content tags
389
383
  const tags = data.tags
390
384
  ? uniqueArray(
391
- data.tags
392
- .replaceAll('/', ',')
393
- .replaceAll('-', ',')
394
- .replaceAll(' ', ',')
395
- .split(',')
396
- .map((t) => t.trim())
397
- .filter((t) => t)
398
- .concat(prefixTags),
399
- )
385
+ data.tags
386
+ .replaceAll('/', ',')
387
+ .replaceAll('-', ',')
388
+ .replaceAll(' ', ',')
389
+ .split(',')
390
+ .map((t) => t.trim())
391
+ .filter((t) => t)
392
+ .concat(prefixTags),
393
+ )
400
394
  : prefixTags;
401
395
  let originObj, originFileObj, indexOriginObj;
402
396
  if (editId) {
@@ -434,7 +428,17 @@ class PanelForm {
434
428
  for (const file of inputFiles) {
435
429
  indexFormDoc++;
436
430
  let fileId = undefined; // Reset for each iteration - only set if user uploaded a file
431
+ // Track whether the file input was explicitly cleared (null) vs never had a file (undefined)
432
+ // In edit mode, null means user cleared the file - we need to tell server to remove it
433
+ const isFileCleared = data.fileId === null && editId;
437
434
  await (async () => {
435
+ // When file is null and not the first iteration or not in edit mode, skip upload
436
+ if (!file && !isFileCleared) return;
437
+ // When user cleared file in edit mode, set fileId=null so server removes the reference
438
+ if (isFileCleared) {
439
+ fileId = null;
440
+ return;
441
+ }
438
442
  const body = new FormData();
439
443
  // Only append md file if it was created (has content)
440
444
  if (md) body.append('md', md);
@@ -485,8 +489,8 @@ class PanelForm {
485
489
  message: documentMessage,
486
490
  data: documentData,
487
491
  } = originObj && indexFormDoc === 0
488
- ? await DocumentService.put({ id: originObj._id, body })
489
- : await DocumentService.post({
492
+ ? await DocumentService.put({ id: originObj._id, body })
493
+ : await DocumentService.post({
490
494
  body,
491
495
  });
492
496
  const newDoc = {
@@ -514,12 +518,12 @@ class PanelForm {
514
518
  fileId: {
515
519
  fileBlob: file
516
520
  ? {
517
- data: {
518
- data: await getDataFromInputFile(file),
519
- },
520
- mimetype: file.type,
521
- name: file.name,
522
- }
521
+ data: {
522
+ data: await getDataFromInputFile(file),
523
+ },
524
+ mimetype: file.type,
525
+ name: file.name,
526
+ }
523
527
  : undefined,
524
528
  filePlain: undefined,
525
529
  },
@@ -738,36 +742,36 @@ class PanelForm {
738
742
  <div
739
743
  class="in fll ssr-shimmer-search-box"
740
744
  style="${renderCssAttr({
741
- style: {
742
- width: '80%',
743
- height: '30px',
744
- top: '-13px',
745
- left: '10px',
746
- },
747
- })}"
745
+ style: {
746
+ width: '80%',
747
+ height: '30px',
748
+ top: '-13px',
749
+ left: '10px',
750
+ },
751
+ })}"
748
752
  ></div>
749
753
  </div>`,
750
754
  createdAt: html`<div class="fl">
751
755
  <div
752
756
  class="in fll ssr-shimmer-search-box"
753
757
  style="${renderCssAttr({
754
- style: {
755
- width: '50%',
756
- height: '30px',
757
- left: '-5px',
758
- },
759
- })}"
758
+ style: {
759
+ width: '50%',
760
+ height: '30px',
761
+ left: '-5px',
762
+ },
763
+ })}"
760
764
  ></div>
761
765
  </div>`,
762
766
  mdFileId: html`<div class="fl section-mp">
763
767
  <div
764
768
  class="in fll ssr-shimmer-search-box"
765
769
  style="${renderCssAttr({
766
- style: {
767
- width: '80%',
768
- height: '30px',
769
- },
770
- })}"
770
+ style: {
771
+ width: '80%',
772
+ height: '30px',
773
+ },
774
+ })}"
771
775
  ></div>
772
776
  </div>`.repeat(random(2, 4)),
773
777
  ssr: true,