@aaqu/fromcubes-portal-react 0.1.0-alpha.22 → 0.1.0-alpha.24

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.
@@ -1,7 +1,34 @@
1
+ /** @module nodes/lib/page-builder */
2
+
3
+ /**
4
+ * HTML page builders for portal-react. Two entry points:
5
+ *
6
+ * - {@link buildPage} — full portal HTML with inlined JS bundle, WS bridge,
7
+ * error overlay, optional connection badge.
8
+ * - {@link buildErrorPage} — minimal HTML served when a build fails AND no
9
+ * previous good build exists for degraded-mode
10
+ * fallback. Polls + WS-reconnects for recovery.
11
+ *
12
+ * Both share the same `#__error_overlay` / `#__error_banner` CSS so the look
13
+ * is consistent across initial-load failure and live build/runtime errors.
14
+ */
15
+
1
16
  /**
2
- * HTML page builders for portal-react.
17
+ * @typedef {Object} ErrorOverlayParams
18
+ * @property {string} title Overlay heading (e.g. "Build Error").
19
+ * @property {string} [hint] Optional one-line user hint under the title.
20
+ * @property {string} message Multi-line error message rendered inside `<pre>`.
21
+ * @property {string} [statusLine] Optional small status line beneath the message.
22
+ * @property {boolean} [statusOk] When true, statusLine is rendered green; otherwise muted.
3
23
  */
4
24
 
25
+ /**
26
+ * HTML-escape a value for safe interpolation into element text or attribute
27
+ * context. Stringifies non-strings.
28
+ *
29
+ * @param {*} s
30
+ * @returns {string}
31
+ */
5
32
  function esc(s) {
6
33
  return String(s)
7
34
  .replace(/&/g, "&amp;")
@@ -10,6 +37,14 @@ function esc(s) {
10
37
  .replace(/"/g, "&quot;");
11
38
  }
12
39
 
40
+ /**
41
+ * Neutralize `</script>` sequences in user-supplied content that will be
42
+ * inlined inside a `<script>` block. Avoids accidental script-tag escape
43
+ * from a string that contains the closing tag literally.
44
+ *
45
+ * @param {*} s
46
+ * @returns {string}
47
+ */
13
48
  function escScript(s) {
14
49
  return String(s).replace(/<\/(script)/gi, "<\\/$1");
15
50
  }
@@ -56,8 +91,14 @@ const ERROR_OVERLAY_CSS = `
56
91
  }
57
92
  `;
58
93
 
59
- // Shared error overlay markup (HTML inside #__error_overlay).
60
- // Used by buildPage (WS error frame, runtime try/catch) and buildErrorPage.
94
+ /**
95
+ * Shared error overlay markup (HTML rendered inside `#__error_overlay`).
96
+ * Used by `buildPage` (WS error frame, runtime try/catch) and
97
+ * `buildErrorPage` so both surface look identical.
98
+ *
99
+ * @param {ErrorOverlayParams} params
100
+ * @returns {string} HTML fragment (no surrounding `<div>` — caller wraps).
101
+ */
61
102
  function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
62
103
  return (
63
104
  `<h1>${esc(title)}</h1>` +
@@ -71,6 +112,25 @@ function errorOverlayInnerHtml({ title, hint, message, statusLine, statusOk }) {
71
112
 
72
113
  const DEFAULT_HINT = "Fix the component code in Node-RED and deploy again.";
73
114
 
115
+ /**
116
+ * Build the full portal HTML page. Inlines the transpiled JS bundle, wires up
117
+ * the `window.__NR` WebSocket bridge (used by `useNodeRed()`), installs the
118
+ * shared error overlay/banner machinery, and optionally renders the
119
+ * connection-status badge in the bottom-right corner.
120
+ *
121
+ * The browser receives no compiler — the inlined `transpiledJs` is the
122
+ * already-bundled IIFE produced by esbuild at deploy time.
123
+ *
124
+ * @param {string} title Document title (`<title>` tag).
125
+ * @param {string} transpiledJs Pre-compiled IIFE bundle from esbuild.
126
+ * @param {string} wsPath WebSocket URL path (e.g. `/fromcubes/<sub>/_ws`).
127
+ * @param {string} customHead Raw HTML inserted into `<head>` (trusted-author).
128
+ * @param {string} cssHash Tailwind CSS bundle hash, or "" to skip the link tag.
129
+ * @param {?Object} user PortalUser object or null when Portal Auth is off.
130
+ * @param {boolean} showWsStatus Render the `#__cs` connection badge.
131
+ * @param {string} adminRoot `RED.settings.httpAdminRoot` (no trailing slash).
132
+ * @returns {string} Complete HTML5 document.
133
+ */
74
134
  function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showWsStatus, adminRoot) {
75
135
  return `<!DOCTYPE html>
76
136
  <html lang="en">
@@ -158,6 +218,16 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
158
218
  // Flushed in onopen so node status can go red even when the
159
219
  // exception fires synchronously during initial bundle execution.
160
220
  _pendingRuntimeError: null,
221
+ // Page-load timestamp + guarded reload: never reload within 2s of
222
+ // load, so a briefly-inconsistent server (error page served while a
223
+ // "ready" version hash is advertised over WS) cannot drive a tight
224
+ // reload loop. Caps recovery to one reload / 2s.
225
+ _loadT: Date.now(),
226
+ _reload() {
227
+ const wait = 2000 - (Date.now() - this._loadT);
228
+ if (wait > 0) setTimeout(() => location.reload(), wait);
229
+ else location.reload();
230
+ },
161
231
  _user: ${user ? escScript(JSON.stringify(user)) : "null"},
162
232
 
163
233
  connect() {
@@ -193,7 +263,7 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
193
263
  // exception caught locally renders the same overlay node and would
194
264
  // otherwise loop reload to runtime-error to reload forever.
195
265
  if (this._buildErrorActive || (this._version && this._version !== m.hash)) {
196
- location.reload();
266
+ this._reload();
197
267
  return;
198
268
  }
199
269
  this._version = m.hash;
@@ -284,6 +354,20 @@ function buildPage(title, transpiledJs, wsPath, customHead, cssHash, user, showW
284
354
  </html>`;
285
355
  }
286
356
 
357
+ /**
358
+ * Build a minimal error page served when a portal build fails AND no
359
+ * previous good build exists for degraded-mode fallback. Includes:
360
+ *
361
+ * - The shared error overlay markup populated with the error message.
362
+ * - A WS reconnect loop that reloads the page on the next `version` frame.
363
+ * - An HTTP HEAD polling loop with linear backoff (1.5×, capped at 10 s)
364
+ * so the page also recovers when Node-RED itself was restarted (WS dies).
365
+ *
366
+ * @param {string} title Browser title (gets ` — Error` suffix).
367
+ * @param {string} error Multi-line error message rendered inside the overlay.
368
+ * @param {string} wsPath WebSocket URL path; pass empty string to skip WS wiring.
369
+ * @returns {string} Complete HTML5 document.
370
+ */
287
371
  function buildErrorPage(title, error, wsPath) {
288
372
  return `<!DOCTYPE html>
289
373
  <html lang="en">
@@ -305,6 +389,16 @@ function buildErrorPage(title, error, wsPath) {
305
389
  const st = document.getElementById('__err_status');
306
390
  const pre = document.querySelector('#__error_overlay pre');
307
391
  let retries = 0;
392
+ // Reload guard: never reload within 2s of this page loading. If the
393
+ // server is briefly inconsistent (serves this error page yet
394
+ // advertises a "ready" version hash over WS), an unguarded reload
395
+ // turns into a tight loop. The guard caps it to one reload / 2s.
396
+ const __loadT = Date.now();
397
+ function __reload() {
398
+ const wait = 2000 - (Date.now() - __loadT);
399
+ if (wait > 0) { setTimeout(function(){ location.reload(); }, wait); }
400
+ else { location.reload(); }
401
+ }
308
402
  function setStatus(text, ok) {
309
403
  if (!st) return;
310
404
  st.textContent = text;
@@ -320,7 +414,7 @@ function buildErrorPage(title, error, wsPath) {
320
414
  ws.onmessage = function(e) {
321
415
  try {
322
416
  const m = JSON.parse(e.data);
323
- if (m.type === 'version' && m.hash) location.reload();
417
+ if (m.type === 'version' && m.hash) __reload();
324
418
  if (m.type === 'error' && pre) pre.textContent = m.message;
325
419
  } catch(_) {}
326
420
  };
@@ -333,11 +427,50 @@ function buildErrorPage(title, error, wsPath) {
333
427
  ws.onerror = function() { ws.close(); };
334
428
  }
335
429
  ${wsPath ? "connect();" : ""}
336
- setInterval(function() {
337
- fetch(location.href, { method: 'HEAD', cache: 'no-store' })
338
- .then(function(r) { if (r.ok) location.reload(); })
339
- .catch(function() {});
340
- }, 3000);
430
+
431
+ /*
432
+ * Recovery polling loop.
433
+ *
434
+ * Why polling alongside the WebSocket? The WS reload pathway only
435
+ * fires when the next deploy actually broadcasts a "version" frame
436
+ * with a non-empty hash. If Node-RED is restarting (process down →
437
+ * up), the WS dies and reconnects do not help — we need an HTTP
438
+ * probe to notice when the runtime returns.
439
+ *
440
+ * Why backoff? A page sitting on a long-broken build would otherwise
441
+ * burn one HEAD request per 3 s indefinitely — across many open
442
+ * tabs that adds up. Linear-ish backoff (1.5×) caps cost while
443
+ * staying responsive to a freshly-recovered runtime.
444
+ *
445
+ * Cap at 10 s so the worst-case wait between successful redeploy
446
+ * and page recovery is bounded.
447
+ */
448
+ var __pollDelay = 3000;
449
+ var __pollIv = null;
450
+ function __schedulePoll() {
451
+ __pollIv = setTimeout(function poll() {
452
+ fetch(location.href, { method: 'HEAD', cache: 'no-store' })
453
+ .then(function(r) {
454
+ if (r.ok) {
455
+ // Stop the loop BEFORE reload — without this, a slow
456
+ // teardown could leave another setTimeout firing during
457
+ // the unload, briefly racing with the new page.
458
+ if (__pollIv) { clearTimeout(__pollIv); __pollIv = null; }
459
+ location.reload();
460
+ return;
461
+ }
462
+ // 4xx/5xx still means the server is up — keep delay short.
463
+ __pollDelay = 3000;
464
+ __schedulePoll();
465
+ })
466
+ .catch(function() {
467
+ // Network error → server probably down. Grow backoff.
468
+ __pollDelay = Math.min(Math.round(__pollDelay * 1.5), 10000);
469
+ __schedulePoll();
470
+ });
471
+ }, __pollDelay);
472
+ }
473
+ __schedulePoll();
341
474
  })();
342
475
  <\/script>
343
476
  </body>
@@ -1,3 +1,5 @@
1
+ /** @module nodes/lib/router */
2
+
1
3
  /**
2
4
  * Pure routing function for portal-react WS outbound messages.
3
5
  *
@@ -10,11 +12,33 @@
10
12
  * 3. msg._client.username → user-cast fallback (O(N) scan)
11
13
  * 4. otherwise → broadcast
12
14
  *
13
- * Returns a shallow summary { mode, delivered } for observability/tests.
15
+ * Returns a shallow summary `{ mode, delivered }` for observability/tests.
14
16
  * The caller is responsible for any side-effects keyed off the mode
15
17
  * (e.g. caching the last broadcast payload for new-client recovery).
16
18
  */
17
19
 
20
+ /**
21
+ * @typedef {Object} RouteContext
22
+ * @property {Map<string, import("ws").WebSocket>} clients portalClient → ws
23
+ * @property {Map<string, Set<import("ws").WebSocket>>} userIndex userId → ws set
24
+ * @property {(ws: any, frame: string, msg: Object) => boolean} sendTo
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} RouteResult
29
+ * @property {"unicast"|"user-cast"|"broadcast"} mode
30
+ * @property {number} delivered
31
+ */
32
+
33
+ /**
34
+ * Pure router — chooses delivery mode based on `msg._client` and dispatches
35
+ * via `ctx.sendTo` (which is responsible for hook checks and send-failure
36
+ * handling). Has no side-effects other than calling `sendTo`.
37
+ *
38
+ * @param {Object} msg
39
+ * @param {RouteContext} ctx
40
+ * @returns {RouteResult}
41
+ */
18
42
  function route(msg, ctx) {
19
43
  const { clients, userIndex, sendTo } = ctx;
20
44
  const target = msg && msg._client;
@@ -22,8 +22,16 @@
22
22
  window.__fcTwClasses = null;
23
23
  let jsxSetupDone = false;
24
24
 
25
- // One-time Monaco setup: compiler opts, diagnostics, extra libs, completion provider.
26
- // Idempotent safe to call multiple times, only runs setup once.
25
+ /**
26
+ * One-time Monaco setup: compiler opts, diagnostics, extra libs,
27
+ * completion providers (Tailwind class, JSX tag, utility symbol).
28
+ * Idempotent — safe to call multiple times, only the first call wires
29
+ * up listeners; subsequent calls just re-apply the compiler/diagnostics
30
+ * blocks because Node-RED resets them whenever the editor reloads.
31
+ *
32
+ * @returns {void}
33
+ * @private
34
+ */
27
35
  function ensureJsxSetup() {
28
36
  if (jsxSetupDone) {
29
37
  console.log(
@@ -439,7 +447,15 @@
439
447
  console.log(PREFIX, "self-close collapse attached to editor");
440
448
  };
441
449
 
442
- // Fetch component names for tag completion
450
+ /**
451
+ * Fetch the current component registry from `/portal-react/registry`
452
+ * and stash the names on `window.__fcComponentNames` so the JSX-tag
453
+ * completion provider can offer `<Button` etc. Re-fetched on every
454
+ * editor open so newly-added components show up without an editor
455
+ * restart.
456
+ * @returns {void}
457
+ * @private
458
+ */
443
459
  function refreshComponentNames() {
444
460
  $.getJSON("portal-react/registry", function (reg) {
445
461
  window.__fcComponentNames = Object.keys(reg);
@@ -547,6 +563,17 @@
547
563
  console.log(PREFIX, "one-time setup complete");
548
564
  }
549
565
 
566
+ /**
567
+ * Push compilerOptions + diagnosticsOptions into Monaco's TS defaults.
568
+ * Called by `ensureJsxSetup` once and re-applied on every editor open
569
+ * because Node-RED's outer Monaco initialiser can reset these between
570
+ * editor sessions. We disable Monaco's TS-side squiggles entirely —
571
+ * the authoritative syntax check is the server-side esbuild pass at
572
+ * deploy time, and Monaco's TS parser produces noisy false positives
573
+ * (1109/1005/1128) on raw JSX.
574
+ * @returns {void}
575
+ * @private
576
+ */
550
577
  function applyCompilerAndDiag() {
551
578
  const jsDef = monaco.typescript.javascriptDefaults;
552
579
  const compilerOpts = {
@@ -583,11 +610,28 @@
583
610
  );
584
611
  }
585
612
 
586
- // Exported for oneditprepare to call before model creation
613
+ /**
614
+ * Re-apply JSX defaults before Monaco model creation. Exported for
615
+ * `oneditprepare` to call. Idempotent — wraps `ensureJsxSetup`.
616
+ * @returns {void}
617
+ */
587
618
  window.__fcApplyJsxDefaults = function () {
588
619
  ensureJsxSetup();
589
620
  };
590
621
 
622
+ /**
623
+ * Lazy-load Monaco from the admin endpoint `/portal-react/vs/` the
624
+ * first time an editor opens. If Monaco is already on `window`
625
+ * (Node-RED bundled its own copy, or another node loaded it first),
626
+ * skip the loader script and run our setup inline.
627
+ *
628
+ * The callback signature is `cb(failed)` — `failed === true` when the
629
+ * loader script threw at load time; editors should fall back to a
630
+ * plain textarea in that case.
631
+ *
632
+ * @param {(failed?: boolean) => void} cb
633
+ * @returns {void}
634
+ */
591
635
  window.__fcLoadMonaco = function (cb) {
592
636
  console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
593
637
  if (window.monaco) {
@@ -667,22 +711,61 @@
667
711
  "}",
668
712
  ].join("\n");
669
713
 
714
+ // FC_NAME_RE / FC_NAME_MAX / FC_NAME_BLACKLIST mirror lib/helpers.js
715
+ // isSafeName so the editor flags invalid names before deploy.
716
+ const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
717
+ const FC_NAME_MAX = 64;
718
+ const FC_NAME_BLACKLIST = [
719
+ "__proto__", "constructor", "prototype", "hasOwnProperty",
720
+ "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
721
+ "toLocaleString",
722
+ ];
723
+ function fcValidateName(v) {
724
+ return (
725
+ typeof v === "string" &&
726
+ v.length > 0 &&
727
+ v.length <= FC_NAME_MAX &&
728
+ FC_NAME_RE.test(v) &&
729
+ FC_NAME_BLACKLIST.indexOf(v) === -1
730
+ );
731
+ }
732
+
670
733
  RED.nodes.registerType("fc-portal-component", {
671
734
  category: "fromcubes",
672
735
  color: "#a8d8ea",
673
736
  defaults: {
674
737
  name: { value: "" },
675
- compName: { value: "StatusCard", required: true },
738
+ compName: {
739
+ value: "StatusCard",
740
+ required: true,
741
+ validate: fcValidateName,
742
+ },
676
743
  compCode: { value: COMP_STARTER },
677
744
  },
678
745
  inputs: 0,
679
746
  outputs: 0,
680
747
  icon: "font-awesome/fa-cube",
681
- paletteLabel: "fromcubes component",
748
+ paletteLabel: "React Component",
749
+ inputLabels: [],
750
+ outputLabels: [],
751
+ /**
752
+ * Canvas label resolver — falls back through `name → compName →
753
+ * "component"` so a freshly-dropped node shows something readable
754
+ * before the user opens the edit dialog.
755
+ * @returns {string}
756
+ */
682
757
  label: function () {
683
758
  return this.name || this.compName || "component";
684
759
  },
685
760
 
761
+ /**
762
+ * Node-RED editor lifecycle: called every time the user opens the
763
+ * edit dialog for this node. Loads Monaco lazily, creates a JSX
764
+ * model with a stable URI (`file:///fc-comp-<nodeId>.jsx`) and
765
+ * attaches it to the `#fcc-monaco` container. Falls back to a
766
+ * `<textarea>` if Monaco fails to load.
767
+ * @returns {void}
768
+ */
686
769
  oneditprepare: function () {
687
770
  const node = this;
688
771
  const code = node.compCode || COMP_STARTER;
@@ -770,6 +853,12 @@
770
853
  });
771
854
  },
772
855
 
856
+ /**
857
+ * Editor → flow: copy Monaco editor contents into the hidden
858
+ * `#node-input-compCode` input and the node's `compCode` property,
859
+ * then dispose Monaco's model + editor to free GPU/CPU resources.
860
+ * @returns {void}
861
+ */
773
862
  oneditsave: function () {
774
863
  console.log("[FC-Monaco] COMP oneditsave");
775
864
  const code = compEditorInstance
@@ -785,6 +874,12 @@
785
874
  }
786
875
  },
787
876
 
877
+ /**
878
+ * User cancelled the dialog — discard Monaco state without writing
879
+ * back to the node. Same dispose pattern as oneditsave to avoid
880
+ * leaking model graphs.
881
+ * @returns {void}
882
+ */
788
883
  oneditcancel: function () {
789
884
  console.log("[FC-Monaco] COMP oneditcancel");
790
885
  if (compEditorInstance) {
@@ -795,6 +890,13 @@
795
890
  }
796
891
  },
797
892
 
893
+ /**
894
+ * Resize handler — Monaco doesn't react to container size changes
895
+ * unless we call `editor.layout()` explicitly. Subtracts ~180 px of
896
+ * chrome (label, buttons, padding) from the dialog height.
897
+ * @param {{height: number, width: number}} size
898
+ * @returns {void}
899
+ */
798
900
  oneditresize: function (size) {
799
901
  let h = size.height - 180;
800
902
  if (h < 150) h = 150;
@@ -872,6 +974,35 @@
872
974
  (function () {
873
975
  let utilEditorInstance = null;
874
976
 
977
+ // Local copy of the identifier validator — each editor <script> block is
978
+ // wrapped in its own IIFE, so the helper defined in the fc-portal-component
979
+ // block above is out of scope here. Mirrors lib/helpers.js isSafeName.
980
+ const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
981
+ const FC_NAME_MAX = 64;
982
+ const FC_NAME_BLACKLIST = [
983
+ "__proto__", "constructor", "prototype", "hasOwnProperty",
984
+ "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
985
+ "toLocaleString",
986
+ ];
987
+ /**
988
+ * Mirror of `lib/helpers.js#isSafeName` — used as `defaults.utilName.validate`
989
+ * so the editor flags invalid identifiers in red before the user can
990
+ * deploy a node that the server would later reject. Rules: non-empty
991
+ * string, ≤ 64 chars, JS identifier syntax, not on the prototype-key
992
+ * blacklist.
993
+ * @param {unknown} v
994
+ * @returns {boolean}
995
+ */
996
+ function fcValidateName(v) {
997
+ return (
998
+ typeof v === "string" &&
999
+ v.length > 0 &&
1000
+ v.length <= FC_NAME_MAX &&
1001
+ FC_NAME_RE.test(v) &&
1002
+ FC_NAME_BLACKLIST.indexOf(v) === -1
1003
+ );
1004
+ }
1005
+
875
1006
  const UTIL_STARTER = [
876
1007
  "// Helpers / custom hooks / constants — top-level, no React component.",
877
1008
  "// Each symbol declared here is available globally in any portal that",
@@ -899,17 +1030,33 @@
899
1030
  color: "#fbbf24",
900
1031
  defaults: {
901
1032
  name: { value: "" },
902
- utilName: { value: "myHelpers", required: true },
1033
+ utilName: {
1034
+ value: "myHelpers",
1035
+ required: true,
1036
+ validate: fcValidateName,
1037
+ },
903
1038
  utilCode: { value: UTIL_STARTER },
904
1039
  },
905
1040
  inputs: 0,
906
1041
  outputs: 0,
907
1042
  icon: "font-awesome/fa-wrench",
908
- paletteLabel: "fromcubes utility",
1043
+ paletteLabel: "React Utility",
1044
+ inputLabels: [],
1045
+ outputLabels: [],
1046
+ /**
1047
+ * Canvas label — `name → utilName → "utility"`.
1048
+ * @returns {string}
1049
+ */
909
1050
  label: function () {
910
1051
  return this.name || this.utilName || "utility";
911
1052
  },
912
1053
 
1054
+ /**
1055
+ * Editor lifecycle for `fc-portal-utility`: lazy-load Monaco,
1056
+ * create a JS model with URI `file:///fc-util-<nodeId>.js` (note
1057
+ * `.js` not `.jsx` — utility code is plain JS).
1058
+ * @returns {void}
1059
+ */
913
1060
  oneditprepare: function () {
914
1061
  const node = this;
915
1062
  const code = node.utilCode || UTIL_STARTER;
@@ -941,6 +1088,11 @@
941
1088
  });
942
1089
  },
943
1090
 
1091
+ /**
1092
+ * Editor → flow: write Monaco contents back to `utilCode`, dispose
1093
+ * model+editor.
1094
+ * @returns {void}
1095
+ */
944
1096
  oneditsave: function () {
945
1097
  const code = utilEditorInstance
946
1098
  ? utilEditorInstance.getValue()
@@ -954,6 +1106,10 @@
954
1106
  }
955
1107
  },
956
1108
 
1109
+ /**
1110
+ * Discard Monaco state without writing back.
1111
+ * @returns {void}
1112
+ */
957
1113
  oneditcancel: function () {
958
1114
  if (utilEditorInstance) {
959
1115
  utilEditorInstance.getModel().dispose();
@@ -962,6 +1118,12 @@
962
1118
  }
963
1119
  },
964
1120
 
1121
+ /**
1122
+ * Layout Monaco on dialog resize. -200 px chrome for the utility
1123
+ * dialog (taller because of the symbol-list panel).
1124
+ * @param {{height: number, width: number}} size
1125
+ * @returns {void}
1126
+ */
965
1127
  oneditresize: function (size) {
966
1128
  let h = size.height - 200;
967
1129
  if (h < 150) h = 150;
@@ -1232,11 +1394,33 @@ function useDebounce(value, ms = 300) {
1232
1394
  inputs: 1,
1233
1395
  outputs: 1,
1234
1396
  icon: "font-awesome/fa-desktop",
1235
- paletteLabel: "fromcubes portal",
1397
+ paletteLabel: "React Portal",
1398
+ // Source AND sink — we keep the default left-aligned input even though
1399
+ // Node-RED appearance docs suggest align: 'right' for nodes that sit at
1400
+ // the end of a flow. This portal is hybrid: the input wire pushes msgs
1401
+ // into the browser, while the output wire forwards UI events out. Left
1402
+ // alignment matches the dominant "msg → render" direction.
1403
+ inputLabels: ["broadcast / unicast msg → page"],
1404
+ outputLabels: ["UI event from page"],
1405
+ /**
1406
+ * Canvas label — shows `name` or the deployed URL (`/fromcubes/<subPath>`).
1407
+ * @returns {string}
1408
+ */
1236
1409
  label: function () {
1237
1410
  return this.name || ("/fromcubes/" + (this.subPath || "?"));
1238
1411
  },
1239
1412
 
1413
+ /**
1414
+ * Editor lifecycle for the portal node. Heaviest of the three —
1415
+ * creates two Monaco editors (JSX + Head HTML), wires up:
1416
+ * - URL hint that previews `/fromcubes/<subPath>`
1417
+ * - Legacy `endpoint` field migration warning
1418
+ * - `RED.tabs` for JSX / Properties / Head HTML panels
1419
+ * - Component picker dialog (`#fc-btn-components`)
1420
+ * - Utility picker dialog (`#fc-btn-utilities`)
1421
+ * - Portal Assets sidebar tab via `RED.sidebar.addTab` (once)
1422
+ * @returns {void}
1423
+ */
1240
1424
  oneditprepare: function () {
1241
1425
  const node = this;
1242
1426
  const code = node.componentCode || STARTER;
@@ -1672,6 +1856,13 @@ function useDebounce(value, ms = 300) {
1672
1856
  });
1673
1857
  },
1674
1858
 
1859
+ /**
1860
+ * Editor → flow. Normalises `subPath` (trim), clears the legacy
1861
+ * `endpoint` field, collects `libs` from the editableList widget,
1862
+ * writes both Monaco editors back to hidden inputs + node props,
1863
+ * then disposes both editors.
1864
+ * @returns {void}
1865
+ */
1675
1866
  oneditsave: function () {
1676
1867
  console.log("[FC-Monaco] PORTAL oneditsave");
1677
1868
 
@@ -1718,6 +1909,12 @@ function useDebounce(value, ms = 300) {
1718
1909
  }
1719
1910
  },
1720
1911
 
1912
+ /**
1913
+ * Discard both Monaco editors without writing back to the node.
1914
+ * Identical dispose pattern to oneditsave so we never leak Monaco
1915
+ * graphs even if the user spams Edit / Cancel rapidly.
1916
+ * @returns {void}
1917
+ */
1721
1918
  oneditcancel: function () {
1722
1919
  console.log("[FC-Monaco] PORTAL oneditcancel");
1723
1920
  if (editorInstance) {
@@ -1734,6 +1931,13 @@ function useDebounce(value, ms = 300) {
1734
1931
  }
1735
1932
  },
1736
1933
 
1934
+ /**
1935
+ * Dialog resize → compute available editor height by subtracting
1936
+ * the heights of non-tab form rows + tab strip + 30 px padding,
1937
+ * then re-layout both Monaco editors. Falls back to 200 px minimum.
1938
+ * @param {{height: number, width: number}} size
1939
+ * @returns {void}
1940
+ */
1737
1941
  oneditresize: function (size) {
1738
1942
  const tabsH = $("#fc-tabs").outerHeight(true) || 0;
1739
1943
  const rows = $(
@@ -1879,6 +2083,11 @@ const { data, send, user, portalClient } = useNodeRed();
1879
2083
  const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1880
2084
  const publicBase = nodeRoot + "/fromcubes/public/";
1881
2085
 
2086
+ /**
2087
+ * Human-readable byte formatter for the assets sidebar.
2088
+ * @param {number} bytes
2089
+ * @returns {string} e.g. `"42 B"`, `"3.2 KB"`, `"1.4 MB"`.
2090
+ */
1882
2091
  function formatSize(bytes) {
1883
2092
  if (bytes < 1024) return bytes + " B";
1884
2093
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
@@ -1902,6 +2111,11 @@ const { data, send, user, portalClient } = useNodeRed();
1902
2111
  .appendTo(toolbar);
1903
2112
 
1904
2113
  // ── Helpers ──
2114
+ /**
2115
+ * Linear scan over the most-recently-fetched asset list.
2116
+ * @param {string} p Relative path inside the assets root.
2117
+ * @returns {boolean}
2118
+ */
1905
2119
  function pathExists(p) {
1906
2120
  for (let i = 0; i < allEntries.length; i++) {
1907
2121
  if (allEntries[i].name === p) return true;
@@ -1909,6 +2123,16 @@ const { data, send, user, portalClient } = useNodeRed();
1909
2123
  return false;
1910
2124
  }
1911
2125
 
2126
+ /**
2127
+ * POST one or more files to the upload endpoint. Prompts before
2128
+ * overwriting existing paths. Each request is sent independently so
2129
+ * an upstream failure of one file does not block the rest; the sidebar
2130
+ * is refreshed once all responses have arrived.
2131
+ * @param {FileList|Array<File>} files
2132
+ * @param {string} targetDir Relative directory inside the assets root
2133
+ * (empty string for root).
2134
+ * @returns {void}
2135
+ */
1912
2136
  function uploadFiles(files, targetDir) {
1913
2137
  if (!files || files.length === 0) return;
1914
2138
  let toUpload = [];
@@ -1976,6 +2200,15 @@ const { data, send, user, portalClient } = useNodeRed();
1976
2200
  }
1977
2201
  });
1978
2202
 
2203
+ /**
2204
+ * Move or rename an asset. Computes the new path from `fromPath`'s basename
2205
+ * + `toDir`. No-ops when source and destination are equal. Prompts before
2206
+ * overwriting an existing destination. Posts to the `move` admin endpoint
2207
+ * (auth- and CSRF-gated server-side); the sidebar refreshes on success.
2208
+ * @param {string} fromPath Current relative path of the item.
2209
+ * @param {string} toDir Destination directory (empty string for root).
2210
+ * @returns {void}
2211
+ */
1979
2212
  function moveItem(fromPath, toDir) {
1980
2213
  const filename = fromPath.split("/").pop();
1981
2214
  const newPath = toDir ? toDir + "/" + filename : filename;
@@ -2347,6 +2580,12 @@ const { data, send, user, portalClient } = useNodeRed();
2347
2580
  });
2348
2581
  }
2349
2582
 
2583
+ /**
2584
+ * Re-fetch the assets listing from the admin API and re-render the tree.
2585
+ * Called after every mutation (upload/move/delete/mkdir) and on the
2586
+ * Node-RED `sidebar:resize` event.
2587
+ * @returns {void}
2588
+ */
2350
2589
  function refreshList() {
2351
2590
  $.getJSON("portal-react/assets", function (entries) {
2352
2591
  allEntries = entries || [];