@aaqu/fromcubes-portal-react 0.1.0-alpha.3 → 0.1.0-alpha.30

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.
@@ -3,7 +3,7 @@
3
3
  ============================================================ -->
4
4
  <script type="text/javascript">
5
5
  (function () {
6
- var PREFIX = "[FC-Monaco]";
6
+ const PREFIX = "[FC-Monaco]";
7
7
 
8
8
  // Shared editor options — used by both component and portal-react editors
9
9
  window.__fcEditorOpts = {
@@ -20,32 +20,51 @@
20
20
  };
21
21
 
22
22
  window.__fcTwClasses = null;
23
- var jsxSetupDone = false;
24
-
25
- // One-time Monaco setup: compiler opts, diagnostics, extra libs, completion provider.
26
- // Idempotent safe to call multiple times, only runs setup once.
23
+ let jsxSetupDone = false;
24
+
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
- console.log(PREFIX, "ensureJsxSetup: already done, re-applying compiler+diag only");
37
+ console.log(
38
+ PREFIX,
39
+ "ensureJsxSetup: already done, re-applying compiler+diag only",
40
+ );
30
41
  // Always re-apply compiler/diag in case Node-RED reset them
31
42
  applyCompilerAndDiag();
32
43
  return;
33
44
  }
34
45
  jsxSetupDone = true;
35
46
 
36
- var jsDef = monaco.typescript.javascriptDefaults;
47
+ const jsDef = monaco.typescript.javascriptDefaults;
37
48
 
38
49
  // Log initial state
39
- console.log(PREFIX, "INITIAL compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
40
- console.log(PREFIX, "INITIAL diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
50
+ console.log(
51
+ PREFIX,
52
+ "INITIAL compilerOptions:",
53
+ JSON.stringify(jsDef.getCompilerOptions()),
54
+ );
55
+ console.log(
56
+ PREFIX,
57
+ "INITIAL diagnosticsOptions:",
58
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
59
+ );
41
60
 
42
61
  applyCompilerAndDiag();
43
62
 
44
63
  // Global type stubs
45
- var libContent = [
46
- "declare var React: any;",
47
- "declare var ReactDOM: any;",
48
- "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void };",
64
+ const libContent = [
65
+ "declare const React: any;",
66
+ "declare const ReactDOM: any;",
67
+ "declare function useNodeRed(opts?: { ignoreRecovery?: boolean }): { data: any; send: (payload: any, topic?: string) => void; user: { userId?: string; userName?: string; username?: string; email?: string; role?: string; groups?: any[] } | null; portalClient: string | null };",
49
68
  ].join("\n");
50
69
  console.log(PREFIX, "addExtraLib globals.d.ts");
51
70
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
@@ -57,44 +76,44 @@
57
76
  monaco.languages.registerCompletionItemProvider("javascript", {
58
77
  triggerCharacters: ['"', "'", "`", " "],
59
78
  provideCompletionItems: function (model, position) {
60
- var line = model.getLineContent(position.lineNumber);
61
- var before = line.substring(0, position.column - 1);
79
+ const line = model.getLineContent(position.lineNumber);
80
+ const before = line.substring(0, position.column - 1);
62
81
 
63
82
  // 1) className="..." or className={`...`}
64
- var isClassName =
83
+ const isClassName =
65
84
  before.match(/className\s*=\s*["'][^"']*$/) ||
66
85
  before.match(/className\s*=\s*\{`[^`]*$/);
67
86
 
68
87
  // 2) Any open string literal: "..., '..., or `...
69
- var inString = !isClassName && before.match(/["'`][^"'`]*$/);
88
+ const inString = !isClassName && before.match(/["'`][^"'`]*$/);
70
89
 
71
90
  if (!isClassName && !inString) return { suggestions: [] };
72
91
 
73
92
  // Extract text inside the string
74
- var raw = (isClassName || inString)[0];
75
- var inside;
93
+ const raw = (isClassName || inString)[0];
94
+ let inside;
76
95
  if (isClassName) {
77
96
  inside = raw.replace(/^className\s*=\s*(?:\{`|["'])/, "");
78
97
  } else {
79
98
  inside = raw.substring(1); // skip opening quote
80
99
  }
81
100
  inside = inside.replace(/\$\{[^}]*\}/g, " ");
82
- var parts = inside.split(/\s+/);
83
- var word = parts[parts.length - 1] || "";
101
+ const parts = inside.split(/\s+/);
102
+ const word = parts[parts.length - 1] || "";
84
103
 
85
104
  // For non-className strings, only activate if at least one existing
86
105
  // token in the string looks like a TW class (avoid noise in random strings)
87
106
  if (!isClassName && parts.length > 0) {
88
- var classes = window.__fcTwClasses || [];
89
- var twSet = window.__fcTwSet;
107
+ const classes = window.__fcTwClasses || [];
108
+ let twSet = window.__fcTwSet;
90
109
  if (!twSet && classes.length) {
91
110
  twSet = new Set(classes);
92
111
  window.__fcTwSet = twSet;
93
112
  console.log(PREFIX, "TW: built lookup Set, size=" + twSet.size);
94
113
  }
95
114
  if (twSet) {
96
- var hasTwToken = false;
97
- for (var p = 0; p < parts.length; p++) {
115
+ let hasTwToken = false;
116
+ for (let p = 0; p < parts.length; p++) {
98
117
  if (parts[p] && twSet.has(parts[p])) {
99
118
  hasTwToken = true;
100
119
  break;
@@ -102,7 +121,7 @@
102
121
  }
103
122
  // If typing the very first word, accept if it looks like a prefix
104
123
  if (!hasTwToken && parts.length === 1 && word.length >= 2) {
105
- for (var j = 0; j < classes.length; j++) {
124
+ for (let j = 0; j < classes.length; j++) {
106
125
  if (classes[j].indexOf(word) === 0) {
107
126
  hasTwToken = true;
108
127
  break;
@@ -113,17 +132,17 @@
113
132
  }
114
133
  }
115
134
 
116
- var range = {
135
+ const range = {
117
136
  startLineNumber: position.lineNumber,
118
137
  endLineNumber: position.lineNumber,
119
138
  startColumn: position.column - word.length,
120
139
  endColumn: position.column,
121
140
  };
122
141
 
123
- var classes = window.__fcTwClasses || [];
124
- var suggestions = [];
125
- for (var i = 0; i < classes.length; i++) {
126
- var cls = classes[i];
142
+ const classes = window.__fcTwClasses || [];
143
+ const suggestions = [];
144
+ for (let i = 0; i < classes.length; i++) {
145
+ const cls = classes[i];
127
146
  if (!word.length || cls.indexOf(word) === 0) {
128
147
  suggestions.push({
129
148
  label: cls,
@@ -134,57 +153,133 @@
134
153
  });
135
154
  }
136
155
  }
137
- console.log(PREFIX, "TW completion: ctx=" + (isClassName ? "className" : "string") + " word='" + word + "', matched=" + suggestions.length + "/" + classes.length);
156
+ console.log(
157
+ PREFIX,
158
+ "TW completion: ctx=" +
159
+ (isClassName ? "className" : "string") +
160
+ " word='" +
161
+ word +
162
+ "', matched=" +
163
+ suggestions.length +
164
+ "/" +
165
+ classes.length,
166
+ );
138
167
  return { suggestions: suggestions };
139
168
  },
140
169
  });
141
170
 
142
171
  // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
143
172
  console.log(PREFIX, "registering JSX tag completion provider");
144
- var HTML_TAGS = [
145
- "div","span","p","a","button","input","img","form","label",
146
- "h1","h2","h3","h4","h5","h6",
147
- "ul","ol","li","dl","dt","dd",
148
- "table","thead","tbody","tfoot","tr","th","td",
149
- "section","article","aside","header","footer","nav","main",
150
- "strong","em","b","i","u","s","small","mark","code","pre","blockquote",
151
- "br","hr","wbr",
152
- "select","option","optgroup","textarea",
153
- "fieldset","legend","details","summary","dialog",
154
- "canvas","video","audio","source","picture","figure","figcaption",
155
- "svg","path","circle","rect","line","g","defs","use","text",
173
+ const HTML_TAGS = [
174
+ "div",
175
+ "span",
176
+ "p",
177
+ "a",
178
+ "button",
179
+ "input",
180
+ "img",
181
+ "form",
182
+ "label",
183
+ "h1",
184
+ "h2",
185
+ "h3",
186
+ "h4",
187
+ "h5",
188
+ "h6",
189
+ "ul",
190
+ "ol",
191
+ "li",
192
+ "dl",
193
+ "dt",
194
+ "dd",
195
+ "table",
196
+ "thead",
197
+ "tbody",
198
+ "tfoot",
199
+ "tr",
200
+ "th",
201
+ "td",
202
+ "section",
203
+ "article",
204
+ "aside",
205
+ "header",
206
+ "footer",
207
+ "nav",
208
+ "main",
209
+ "strong",
210
+ "em",
211
+ "b",
212
+ "i",
213
+ "u",
214
+ "s",
215
+ "small",
216
+ "mark",
217
+ "code",
218
+ "pre",
219
+ "blockquote",
220
+ "br",
221
+ "hr",
222
+ "wbr",
223
+ "select",
224
+ "option",
225
+ "optgroup",
226
+ "textarea",
227
+ "fieldset",
228
+ "legend",
229
+ "details",
230
+ "summary",
231
+ "dialog",
232
+ "canvas",
233
+ "video",
234
+ "audio",
235
+ "source",
236
+ "picture",
237
+ "figure",
238
+ "figcaption",
239
+ "svg",
240
+ "path",
241
+ "circle",
242
+ "rect",
243
+ "line",
244
+ "g",
245
+ "defs",
246
+ "use",
247
+ "text",
156
248
  ];
157
- var VOID_TAGS = new Set(["br","hr","img","input","wbr","source"]);
249
+ const VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
158
250
 
159
251
  monaco.languages.registerCompletionItemProvider("javascript", {
160
252
  triggerCharacters: ["<"],
161
253
  provideCompletionItems: function (model, position) {
162
- var line = model.getLineContent(position.lineNumber);
163
- var before = line.substring(0, position.column - 1);
254
+ const line = model.getLineContent(position.lineNumber);
255
+ const before = line.substring(0, position.column - 1);
164
256
 
165
257
  // After "<" — suggest tags
166
- var afterBracket = before.match(/<([a-zA-Z]*)$/);
258
+ const afterBracket = before.match(/<([a-zA-Z]*)$/);
167
259
  // Bare word at line start or after whitespace/{ — Emmet-like (lower or upper)
168
- var bareWord = !afterBracket && before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
260
+ const bareWord =
261
+ !afterBracket &&
262
+ before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
169
263
 
170
264
  if (!afterBracket && !bareWord) return { suggestions: [] };
171
265
 
172
- var typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
173
- var replaceStart = position.column - typed.length - (afterBracket ? 1 : 0);
266
+ const typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
267
+ const replaceStart =
268
+ position.column - typed.length - (afterBracket ? 1 : 0);
174
269
 
175
- var range = {
270
+ const range = {
176
271
  startLineNumber: position.lineNumber,
177
272
  endLineNumber: position.lineNumber,
178
273
  startColumn: replaceStart,
179
274
  endColumn: position.column,
180
275
  };
181
276
 
182
- var suggestions = [];
183
- var seen = new Set();
277
+ const suggestions = [];
278
+ const seen = new Set();
184
279
 
185
280
  // HTML tags
186
- for (var i = 0; i < HTML_TAGS.length; i++) {
187
- var tag = HTML_TAGS[i];
281
+ for (let i = 0; i < HTML_TAGS.length; i++) {
282
+ const tag = HTML_TAGS[i];
188
283
  if (typed && tag.indexOf(typed) !== 0) continue;
189
284
  seen.add(tag);
190
285
  if (VOID_TAGS.has(tag)) {
@@ -192,7 +287,8 @@
192
287
  label: tag,
193
288
  kind: monaco.languages.CompletionItemKind.Property,
194
289
  insertText: "<" + tag + " $0/>",
195
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
290
+ insertTextRules:
291
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
196
292
  range: range,
197
293
  detail: "<" + tag + " />",
198
294
  sortText: "0" + tag,
@@ -202,7 +298,8 @@
202
298
  label: tag,
203
299
  kind: monaco.languages.CompletionItemKind.Property,
204
300
  insertText: "<" + tag + ">$0</" + tag + ">",
205
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
301
+ insertTextRules:
302
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
206
303
  range: range,
207
304
  detail: "<" + tag + ">…</" + tag + ">",
208
305
  sortText: "0" + tag + "a",
@@ -211,7 +308,8 @@
211
308
  label: tag,
212
309
  kind: monaco.languages.CompletionItemKind.Property,
213
310
  insertText: "<" + tag + " $0/>",
214
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
311
+ insertTextRules:
312
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
215
313
  range: range,
216
314
  detail: "<" + tag + " />",
217
315
  sortText: "0" + tag + "b",
@@ -220,24 +318,36 @@
220
318
  }
221
319
 
222
320
  // Custom components from registry (PascalCase)
223
- var upperMatch = afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
224
- var bareUpper = !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
225
- var compTyped = upperMatch ? upperMatch[1] : (bareUpper ? bareUpper[1] : "");
321
+ const upperMatch =
322
+ afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
323
+ const bareUpper =
324
+ !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
325
+ const compTyped = upperMatch
326
+ ? upperMatch[1]
327
+ : bareUpper
328
+ ? bareUpper[1]
329
+ : "";
226
330
 
227
331
  if (compTyped || (!typed && afterBracket)) {
228
332
  // Fetch component names from registry (cached on window)
229
- var reg = window.__fcComponentNames || [];
230
- for (var c = 0; c < reg.length; c++) {
231
- var name = reg[c];
333
+ const reg = window.__fcComponentNames || [];
334
+ for (let c = 0; c < reg.length; c++) {
335
+ const name = reg[c];
232
336
  if (seen.has(name)) continue;
233
337
  if (compTyped && name.indexOf(compTyped) !== 0) continue;
234
- if (!compTyped && typed && name.toLowerCase().indexOf(typed) !== 0) continue;
338
+ if (
339
+ !compTyped &&
340
+ typed &&
341
+ name.toLowerCase().indexOf(typed) !== 0
342
+ )
343
+ continue;
235
344
  seen.add(name);
236
345
  suggestions.push({
237
346
  label: name,
238
347
  kind: monaco.languages.CompletionItemKind.Class,
239
348
  insertText: "<" + name + ">$0</" + name + ">",
240
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
349
+ insertTextRules:
350
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
241
351
  range: range,
242
352
  detail: "<" + name + ">…</" + name + ">",
243
353
  sortText: "0" + name + "a",
@@ -246,7 +356,8 @@
246
356
  label: name,
247
357
  kind: monaco.languages.CompletionItemKind.Class,
248
358
  insertText: "<" + name + " $0/>",
249
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
359
+ insertTextRules:
360
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
250
361
  range: range,
251
362
  detail: "<" + name + " />",
252
363
  sortText: "0" + name + "b",
@@ -258,7 +369,8 @@
258
369
  label: compTyped,
259
370
  kind: monaco.languages.CompletionItemKind.Class,
260
371
  insertText: "<" + compTyped + ">$0</" + compTyped + ">",
261
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
372
+ insertTextRules:
373
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
262
374
  range: range,
263
375
  detail: "<" + compTyped + ">…</" + compTyped + ">",
264
376
  sortText: "0" + compTyped + "a",
@@ -267,7 +379,8 @@
267
379
  label: compTyped,
268
380
  kind: monaco.languages.CompletionItemKind.Class,
269
381
  insertText: "<" + compTyped + " $0/>",
270
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
382
+ insertTextRules:
383
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
271
384
  range: range,
272
385
  detail: "<" + compTyped + " />",
273
386
  sortText: "0" + compTyped + "b",
@@ -275,7 +388,13 @@
275
388
  }
276
389
  }
277
390
 
278
- console.log(PREFIX, "JSX tag completion: typed='" + typed + "', suggestions=" + suggestions.length);
391
+ console.log(
392
+ PREFIX,
393
+ "JSX tag completion: typed='" +
394
+ typed +
395
+ "', suggestions=" +
396
+ suggestions.length,
397
+ );
279
398
  return { suggestions: suggestions };
280
399
  },
281
400
  });
@@ -285,69 +404,155 @@
285
404
  window.__fcAttachSelfClose = function (editor) {
286
405
  editor.onDidChangeModelContent(function (e) {
287
406
  if (e.changes.length !== 1) return;
288
- var ch = e.changes[0];
407
+ const ch = e.changes[0];
289
408
  if (ch.text !== "/") return;
290
- var model = editor.getModel();
409
+ const model = editor.getModel();
291
410
  if (!model) return;
292
- var lineNum = ch.range.startLineNumber;
293
- var line = model.getLineContent(lineNum);
411
+ const lineNum = ch.range.startLineNumber;
412
+ const line = model.getLineContent(lineNum);
294
413
  // Pattern 1: <tag>/</tag> (cursor was between > and </)
295
414
  // Pattern 2: <tag/></tag> (cursor was before > in opening tag)
296
- var m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
297
- var matchStr, tag, prefix;
415
+ let m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
416
+ let matchStr, tag, prefix;
298
417
  if (m) {
299
- prefix = m[1]; tag = m[2];
418
+ prefix = m[1];
419
+ tag = m[2];
300
420
  matchStr = "<" + tag + ">/</" + tag + ">";
301
421
  } else {
302
422
  m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)\/><\/\2>(.*)$/);
303
423
  if (!m) return;
304
- prefix = m[1]; tag = m[2];
424
+ prefix = m[1];
425
+ tag = m[2];
305
426
  matchStr = "<" + tag + "/></" + tag + ">";
306
427
  }
307
- var startCol = prefix.length + 1;
308
- var endCol = startCol + matchStr.length;
309
- var replacement = "<" + tag + " />";
310
- var cursorCol = startCol + replacement.length - 2;
428
+ const startCol = prefix.length + 1;
429
+ const endCol = startCol + matchStr.length;
430
+ const replacement = "<" + tag + " />";
431
+ const cursorCol = startCol + replacement.length - 2;
311
432
  setTimeout(function () {
312
- editor.executeEdits("self-close-collapse", [{
313
- range: {
314
- startLineNumber: lineNum,
315
- endLineNumber: lineNum,
316
- startColumn: startCol,
317
- endColumn: endCol,
433
+ editor.executeEdits("self-close-collapse", [
434
+ {
435
+ range: {
436
+ startLineNumber: lineNum,
437
+ endLineNumber: lineNum,
438
+ startColumn: startCol,
439
+ endColumn: endCol,
440
+ },
441
+ text: replacement,
318
442
  },
319
- text: replacement,
320
- }]);
443
+ ]);
321
444
  editor.setPosition({ lineNumber: lineNum, column: cursorCol });
322
445
  }, 0);
323
446
  });
324
447
  console.log(PREFIX, "self-close collapse attached to editor");
325
448
  };
326
449
 
327
- // 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
+ */
328
459
  function refreshComponentNames() {
329
460
  $.getJSON("portal-react/registry", function (reg) {
330
461
  window.__fcComponentNames = Object.keys(reg);
331
- console.log(PREFIX, "component names loaded:", window.__fcComponentNames);
462
+ console.log(
463
+ PREFIX,
464
+ "component names loaded:",
465
+ window.__fcComponentNames,
466
+ );
332
467
  }).fail(function () {
333
468
  window.__fcComponentNames = [];
334
469
  });
335
470
  }
336
471
  refreshComponentNames();
337
472
 
473
+ // Fetch utility nodes + their parsed top-level symbols.
474
+ // Window cache shape: { byNode: { utilName: [symbol, ...] }, allSymbols: [..] }
475
+ window.__fcRefreshUtilities = function () {
476
+ $.getJSON("portal-react/utilities", function (reg) {
477
+ const byNode = {};
478
+ const all = new Set();
479
+ Object.keys(reg).forEach(function (n) {
480
+ const syms = reg[n].symbols || [];
481
+ byNode[n] = syms;
482
+ syms.forEach(function (s) { all.add(s); });
483
+ });
484
+ window.__fcUtilities = { byNode: byNode, allSymbols: [...all] };
485
+ console.log(
486
+ PREFIX,
487
+ "utilities loaded: " + Object.keys(byNode).length + " nodes, " + all.size + " symbols",
488
+ );
489
+ }).fail(function () {
490
+ window.__fcUtilities = { byNode: {}, allSymbols: [] };
491
+ });
492
+ };
493
+ window.__fcRefreshUtilities();
494
+
495
+ // Utility-symbol completion: suggests bare identifiers anywhere in JS
496
+ // context EXCEPT after "<" (that's JSX tag completion, handled below).
497
+ monaco.languages.registerCompletionItemProvider("javascript", {
498
+ triggerCharacters: ["."], // jQuery-ish — but we mostly fire from word context
499
+ provideCompletionItems: function (model, position) {
500
+ const line = model.getLineContent(position.lineNumber);
501
+ const before = line.substring(0, position.column - 1);
502
+ // Skip JSX tag context — covered by other providers
503
+ if (/<[a-zA-Z]*$/.test(before)) return { suggestions: [] };
504
+ // Skip property access — let Monaco's language service handle it
505
+ if (/\.\s*\w*$/.test(before)) return { suggestions: [] };
506
+ // Skip inside strings
507
+ if (/["'`][^"'`]*$/.test(before)) return { suggestions: [] };
508
+ const wordMatch = before.match(/([A-Za-z_$][\w$]*)$/);
509
+ const word = wordMatch ? wordMatch[1] : "";
510
+ const all = (window.__fcUtilities && window.__fcUtilities.allSymbols) || [];
511
+ if (!all.length) return { suggestions: [] };
512
+ const range = {
513
+ startLineNumber: position.lineNumber,
514
+ endLineNumber: position.lineNumber,
515
+ startColumn: position.column - word.length,
516
+ endColumn: position.column,
517
+ };
518
+ const suggestions = [];
519
+ for (let i = 0; i < all.length; i++) {
520
+ const sym = all[i];
521
+ if (word && sym.indexOf(word) !== 0) continue;
522
+ suggestions.push({
523
+ label: sym,
524
+ kind: monaco.languages.CompletionItemKind.Function,
525
+ insertText: sym,
526
+ range: range,
527
+ detail: "fc-portal-utility",
528
+ sortText: "1" + sym,
529
+ });
530
+ }
531
+ return { suggestions: suggestions };
532
+ },
533
+ });
534
+
338
535
  // Marker listener for debugging
339
536
  monaco.editor.onDidChangeMarkers(function (uris) {
340
537
  uris.forEach(function (uri) {
341
- var markers = monaco.editor.getModelMarkers({ resource: uri });
538
+ const markers = monaco.editor.getModelMarkers({ resource: uri });
342
539
  if (markers.length > 0) {
343
540
  console.group(PREFIX + " MARKERS on " + uri.toString());
344
541
  markers.forEach(function (m) {
345
542
  console.log(
346
- " code=" + (m.code || "?") +
347
- " severity=" + m.severity +
348
- " source=" + (m.source || "?") +
349
- " msg=" + m.message +
350
- " [L" + m.startLineNumber + ":" + m.startColumn + "]"
543
+ " code=" +
544
+ (m.code || "?") +
545
+ " severity=" +
546
+ m.severity +
547
+ " source=" +
548
+ (m.source || "?") +
549
+ " msg=" +
550
+ m.message +
551
+ " [L" +
552
+ m.startLineNumber +
553
+ ":" +
554
+ m.startColumn +
555
+ "]",
351
556
  );
352
557
  });
353
558
  console.groupEnd();
@@ -358,9 +563,20 @@
358
563
  console.log(PREFIX, "one-time setup complete");
359
564
  }
360
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
+ */
361
577
  function applyCompilerAndDiag() {
362
- var jsDef = monaco.typescript.javascriptDefaults;
363
- var compilerOpts = {
578
+ const jsDef = monaco.typescript.javascriptDefaults;
579
+ const compilerOpts = {
364
580
  jsx: monaco.typescript.JsxEmit.React,
365
581
  target: monaco.typescript.ScriptTarget.ESNext,
366
582
  module: monaco.typescript.ModuleKind.ESNext,
@@ -370,33 +586,64 @@
370
586
  console.log(PREFIX, "setCompilerOptions:", JSON.stringify(compilerOpts));
371
587
  jsDef.setCompilerOptions(compilerOpts);
372
588
 
373
- var diagOpts = {
589
+ const diagOpts = {
374
590
  noSemanticValidation: true,
375
- noSyntaxValidation: false,
591
+ // Server-side esbuild does the real syntax check at deploy time;
592
+ // Monaco's TS parser produces noisy false positives on raw JSX
593
+ // (1109, 1005, 1128 etc.), so we silence its squiggles entirely.
594
+ noSyntaxValidation: true,
376
595
  noSuggestionDiagnostics: true,
377
596
  diagnosticCodesToIgnore: [17004],
378
597
  };
379
598
  console.log(PREFIX, "setDiagnosticsOptions:", JSON.stringify(diagOpts));
380
599
  jsDef.setDiagnosticsOptions(diagOpts);
381
600
 
382
- console.log(PREFIX, "readback compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
383
- console.log(PREFIX, "readback diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
601
+ console.log(
602
+ PREFIX,
603
+ "readback compilerOptions:",
604
+ JSON.stringify(jsDef.getCompilerOptions()),
605
+ );
606
+ console.log(
607
+ PREFIX,
608
+ "readback diagnosticsOptions:",
609
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
610
+ );
384
611
  }
385
612
 
386
- // 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
+ */
387
618
  window.__fcApplyJsxDefaults = function () {
388
619
  ensureJsxSetup();
389
620
  };
390
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
+ */
391
635
  window.__fcLoadMonaco = function (cb) {
392
636
  console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
393
637
  if (window.monaco) {
394
- console.log(PREFIX, "Monaco already loaded (by Node-RED?), running setup inline");
638
+ console.log(
639
+ PREFIX,
640
+ "Monaco already loaded (by Node-RED?), running setup inline",
641
+ );
395
642
  ensureJsxSetup();
396
643
  cb();
397
644
  return;
398
645
  }
399
- var s = document.createElement("script");
646
+ const s = document.createElement("script");
400
647
  s.src = "portal-react/vs/loader.js";
401
648
  s.onload = function () {
402
649
  console.log(PREFIX, "loader.js loaded, configuring require paths");
@@ -429,26 +676,6 @@
429
676
  <label for="node-input-compName"><i class="fa fa-cube"></i> JSX Tag</label>
430
677
  <input type="text" id="node-input-compName" placeholder="MyComponent" />
431
678
  </div>
432
- <div class="form-row" style="display:flex;gap:8px;">
433
- <div style="flex:1">
434
- <label><i class="fa fa-sign-in"></i> Input fields</label>
435
- <input
436
- type="text"
437
- id="node-input-compInputs"
438
- placeholder="payload,topic"
439
- style="width:100%"
440
- />
441
- </div>
442
- <div style="flex:1">
443
- <label><i class="fa fa-sign-out"></i> Output fields</label>
444
- <input
445
- type="text"
446
- id="node-input-compOutputs"
447
- placeholder="payload,topic"
448
- style="width:100%"
449
- />
450
- </div>
451
- </div>
452
679
  <div class="form-row" style="margin-bottom:4px;">
453
680
  <label><i class="fa fa-code"></i> JSX Code</label>
454
681
  </div>
@@ -469,9 +696,9 @@
469
696
 
470
697
  <script type="text/javascript">
471
698
  (function () {
472
- var compEditorInstance = null;
699
+ let compEditorInstance = null;
473
700
 
474
- var COMP_STARTER = [
701
+ const COMP_STARTER = [
475
702
  "function StatusCard({ label, value, unit }) {",
476
703
  " return (",
477
704
  ' <div className="rounded-2xl bg-zinc-900 border border-zinc-800 p-6">',
@@ -484,59 +711,119 @@
484
711
  "}",
485
712
  ].join("\n");
486
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
+
487
733
  RED.nodes.registerType("fc-portal-component", {
488
734
  category: "fromcubes",
489
735
  color: "#a8d8ea",
490
736
  defaults: {
491
737
  name: { value: "" },
492
- compName: { value: "StatusCard", required: true },
493
- compCode: { value: COMP_STARTER },
494
- compInputs: { value: "label,value,unit" },
495
- compOutputs: { value: "" },
738
+ compName: {
739
+ value: "StatusCard",
740
+ required: true,
741
+ validate: fcValidateName,
742
+ },
743
+ compCode: {
744
+ value: COMP_STARTER,
745
+ validate: function (v) {
746
+ return typeof v === "string" && v.trim().length > 0;
747
+ },
748
+ },
496
749
  },
497
750
  inputs: 0,
498
751
  outputs: 0,
499
752
  icon: "font-awesome/fa-cube",
500
- paletteLabel: "component",
753
+ paletteLabel: "React Component",
754
+ inputLabels: [],
755
+ outputLabels: [],
756
+ /**
757
+ * Canvas label resolver — falls back through `name → compName →
758
+ * "component"` so a freshly-dropped node shows something readable
759
+ * before the user opens the edit dialog.
760
+ * @returns {string}
761
+ */
501
762
  label: function () {
502
763
  return this.name || this.compName || "component";
503
764
  },
504
765
 
766
+ /**
767
+ * Node-RED editor lifecycle: called every time the user opens the
768
+ * edit dialog for this node. Loads Monaco lazily, creates a JSX
769
+ * model with a stable URI (`file:///fc-comp-<nodeId>.jsx`) and
770
+ * attaches it to the `#fcc-monaco` container. Falls back to a
771
+ * `<textarea>` if Monaco fails to load.
772
+ * @returns {void}
773
+ */
505
774
  oneditprepare: function () {
506
- var node = this;
507
- var code = node.compCode || COMP_STARTER;
775
+ const node = this;
776
+ const code =
777
+ typeof node.compCode === "string" ? node.compCode : COMP_STARTER;
508
778
  console.log("[FC-Monaco] COMP oneditprepare, node.id=" + node.id);
509
779
 
510
780
  if (!window.__fcTwClasses) {
511
781
  console.log("[FC-Monaco] loading tw-classes...");
512
782
  $.getJSON("portal-react/tw-classes", function (classes) {
513
- console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
783
+ console.log(
784
+ "[FC-Monaco] tw-classes loaded, count=" + classes.length,
785
+ );
514
786
  window.__fcTwClasses = classes;
515
787
  }).fail(function (xhr) {
516
- console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
788
+ console.error(
789
+ "[FC-Monaco] tw-classes FAILED:",
790
+ xhr.status,
791
+ xhr.statusText,
792
+ );
517
793
  });
518
794
  }
519
795
 
520
796
  window.__fcLoadMonaco(function (failed) {
521
797
  if (failed) {
522
- console.error("[FC-Monaco] COMP: Monaco load failed, using fallback textarea");
798
+ console.error(
799
+ "[FC-Monaco] COMP: Monaco load failed, using fallback textarea",
800
+ );
523
801
  $("#fcc-monaco").hide();
524
802
  $("#fcc-fallback").show().val(code);
525
803
  return;
526
804
  }
527
805
 
528
- console.log("[FC-Monaco] COMP: applying JSX defaults before model creation");
806
+ console.log(
807
+ "[FC-Monaco] COMP: applying JSX defaults before model creation",
808
+ );
529
809
  window.__fcApplyJsxDefaults();
530
810
 
531
- var compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
811
+ const compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
532
812
  console.log("[FC-Monaco] COMP: model URI=" + compUri.toString());
533
- var existingModel = monaco.editor.getModel(compUri);
813
+ const existingModel = monaco.editor.getModel(compUri);
534
814
  if (existingModel) {
535
815
  console.log("[FC-Monaco] COMP: disposing existing model");
536
816
  existingModel.dispose();
537
817
  }
538
- var compModel = monaco.editor.createModel(code, "javascript", compUri);
539
- console.log("[FC-Monaco] COMP: model created, language=" + compModel.getLanguageId());
818
+ const compModel = monaco.editor.createModel(
819
+ code,
820
+ "javascript",
821
+ compUri,
822
+ );
823
+ console.log(
824
+ "[FC-Monaco] COMP: model created, language=" +
825
+ compModel.getLanguageId(),
826
+ );
540
827
 
541
828
  compEditorInstance = monaco.editor.create(
542
829
  document.getElementById("fcc-monaco"),
@@ -546,21 +833,41 @@
546
833
 
547
834
  // Log markers after a short delay (diagnostics are async)
548
835
  setTimeout(function () {
549
- var markers = monaco.editor.getModelMarkers({ resource: compUri });
550
- console.log("[FC-Monaco] COMP: markers after 500ms, count=" + markers.length);
836
+ const markers = monaco.editor.getModelMarkers({ resource: compUri });
837
+ console.log(
838
+ "[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
839
+ );
551
840
  markers.forEach(function (m) {
552
- console.log("[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message);
841
+ console.log(
842
+ "[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message,
843
+ );
553
844
  });
554
845
  // Also log current compiler options state
555
- console.log("[FC-Monaco] COMP: current compilerOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getCompilerOptions()));
556
- console.log("[FC-Monaco] COMP: current diagnosticsOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getDiagnosticsOptions()));
846
+ console.log(
847
+ "[FC-Monaco] COMP: current compilerOptions:",
848
+ JSON.stringify(
849
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
850
+ ),
851
+ );
852
+ console.log(
853
+ "[FC-Monaco] COMP: current diagnosticsOptions:",
854
+ JSON.stringify(
855
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
856
+ ),
857
+ );
557
858
  }, 500);
558
859
  });
559
860
  },
560
861
 
862
+ /**
863
+ * Editor → flow: copy Monaco editor contents into the hidden
864
+ * `#node-input-compCode` input and the node's `compCode` property,
865
+ * then dispose Monaco's model + editor to free GPU/CPU resources.
866
+ * @returns {void}
867
+ */
561
868
  oneditsave: function () {
562
869
  console.log("[FC-Monaco] COMP oneditsave");
563
- var code = compEditorInstance
870
+ const code = compEditorInstance
564
871
  ? compEditorInstance.getValue()
565
872
  : $("#fcc-fallback").val();
566
873
  $("#node-input-compCode").val(code);
@@ -573,6 +880,12 @@
573
880
  }
574
881
  },
575
882
 
883
+ /**
884
+ * User cancelled the dialog — discard Monaco state without writing
885
+ * back to the node. Same dispose pattern as oneditsave to avoid
886
+ * leaking model graphs.
887
+ * @returns {void}
888
+ */
576
889
  oneditcancel: function () {
577
890
  console.log("[FC-Monaco] COMP oneditcancel");
578
891
  if (compEditorInstance) {
@@ -583,8 +896,15 @@
583
896
  }
584
897
  },
585
898
 
899
+ /**
900
+ * Resize handler — Monaco doesn't react to container size changes
901
+ * unless we call `editor.layout()` explicitly. Subtracts ~180 px of
902
+ * chrome (label, buttons, padding) from the dialog height.
903
+ * @param {{height: number, width: number}} size
904
+ * @returns {void}
905
+ */
586
906
  oneditresize: function (size) {
587
- var h = size.height - 180;
907
+ let h = size.height - 180;
588
908
  if (h < 150) h = 150;
589
909
  $("#fcc-editor-wrap").css("height", h + "px");
590
910
  if (compEditorInstance) compEditorInstance.layout();
@@ -622,6 +942,264 @@
622
942
  </p>
623
943
  </script>
624
944
 
945
+ <!-- ============================================================
946
+ fc-portal-utility – canvas node (helpers / hooks / constants)
947
+ ============================================================ -->
948
+ <script type="text/html" data-template-name="fc-portal-utility">
949
+ <div class="form-row">
950
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
951
+ <input type="text" id="node-input-name" placeholder="Label on canvas" />
952
+ </div>
953
+ <div class="form-row">
954
+ <label for="node-input-utilName"><i class="fa fa-wrench"></i> Module name</label>
955
+ <input type="text" id="node-input-utilName" placeholder="mathHelpers" />
956
+ </div>
957
+ <div class="form-row" style="margin-bottom:4px;">
958
+ <label><i class="fa fa-code"></i> Code</label>
959
+ <div style="font-size:11px;opacity:.5;margin-top:2px;">
960
+ Top-level helpers, custom hooks and constants. Available globally to portals
961
+ that reference any of the symbols declared here.
962
+ </div>
963
+ </div>
964
+ <div class="form-row node-text-editor-row">
965
+ <div
966
+ id="fcu-editor-wrap"
967
+ style="width:100%;height:350px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
968
+ >
969
+ <div id="fcu-monaco" style="width:100%;height:100%;"></div>
970
+ <textarea
971
+ id="fcu-fallback"
972
+ style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
973
+ ></textarea>
974
+ </div>
975
+ </div>
976
+ <input type="hidden" id="node-input-utilCode" />
977
+ </script>
978
+
979
+ <script type="text/javascript">
980
+ (function () {
981
+ let utilEditorInstance = null;
982
+
983
+ // Local copy of the identifier validator — each editor <script> block is
984
+ // wrapped in its own IIFE, so the helper defined in the fc-portal-component
985
+ // block above is out of scope here. Mirrors lib/helpers.js isSafeName.
986
+ const FC_NAME_RE = /^[A-Za-z_$][\w$]*$/;
987
+ const FC_NAME_MAX = 64;
988
+ const FC_NAME_BLACKLIST = [
989
+ "__proto__", "constructor", "prototype", "hasOwnProperty",
990
+ "isPrototypeOf", "propertyIsEnumerable", "toString", "valueOf",
991
+ "toLocaleString",
992
+ ];
993
+ /**
994
+ * Mirror of `lib/helpers.js#isSafeName` — used as `defaults.utilName.validate`
995
+ * so the editor flags invalid identifiers in red before the user can
996
+ * deploy a node that the server would later reject. Rules: non-empty
997
+ * string, ≤ 64 chars, JS identifier syntax, not on the prototype-key
998
+ * blacklist.
999
+ * @param {unknown} v
1000
+ * @returns {boolean}
1001
+ */
1002
+ function fcValidateName(v) {
1003
+ return (
1004
+ typeof v === "string" &&
1005
+ v.length > 0 &&
1006
+ v.length <= FC_NAME_MAX &&
1007
+ FC_NAME_RE.test(v) &&
1008
+ FC_NAME_BLACKLIST.indexOf(v) === -1
1009
+ );
1010
+ }
1011
+
1012
+ const UTIL_STARTER = [
1013
+ "// Helpers / custom hooks / constants — top-level, no React component.",
1014
+ "// Each symbol declared here is available globally in any portal that",
1015
+ "// references it (selective inclusion: unused utility nodes are skipped).",
1016
+ "",
1017
+ "const PI2 = Math.PI * 2;",
1018
+ "",
1019
+ "function clamp(n, min, max) {",
1020
+ " return Math.max(min, Math.min(max, n));",
1021
+ "}",
1022
+ "",
1023
+ "// Custom React hook — call from inside App() or library components",
1024
+ "function useDebounce(value, ms = 300) {",
1025
+ " const [v, setV] = React.useState(value);",
1026
+ " React.useEffect(() => {",
1027
+ " const t = setTimeout(() => setV(value), ms);",
1028
+ " return () => clearTimeout(t);",
1029
+ " }, [value, ms]);",
1030
+ " return v;",
1031
+ "}",
1032
+ ].join("\n");
1033
+
1034
+ RED.nodes.registerType("fc-portal-utility", {
1035
+ category: "fromcubes",
1036
+ color: "#fbbf24",
1037
+ defaults: {
1038
+ name: { value: "" },
1039
+ utilName: {
1040
+ value: "myHelpers",
1041
+ required: true,
1042
+ validate: fcValidateName,
1043
+ },
1044
+ utilCode: {
1045
+ value: UTIL_STARTER,
1046
+ validate: function (v) {
1047
+ return typeof v === "string" && v.trim().length > 0;
1048
+ },
1049
+ },
1050
+ },
1051
+ inputs: 0,
1052
+ outputs: 0,
1053
+ icon: "font-awesome/fa-wrench",
1054
+ paletteLabel: "React Utility",
1055
+ inputLabels: [],
1056
+ outputLabels: [],
1057
+ /**
1058
+ * Canvas label — `name → utilName → "utility"`.
1059
+ * @returns {string}
1060
+ */
1061
+ label: function () {
1062
+ return this.name || this.utilName || "utility";
1063
+ },
1064
+
1065
+ /**
1066
+ * Editor lifecycle for `fc-portal-utility`: lazy-load Monaco,
1067
+ * create a JS model with URI `file:///fc-util-<nodeId>.js` (note
1068
+ * `.js` not `.jsx` — utility code is plain JS).
1069
+ * @returns {void}
1070
+ */
1071
+ oneditprepare: function () {
1072
+ const node = this;
1073
+ const code =
1074
+ typeof node.utilCode === "string" ? node.utilCode : UTIL_STARTER;
1075
+ console.log("[FC-Monaco] UTIL oneditprepare, node.id=" + node.id);
1076
+
1077
+ if (!window.__fcTwClasses) {
1078
+ $.getJSON("portal-react/tw-classes", function (classes) {
1079
+ window.__fcTwClasses = classes;
1080
+ });
1081
+ }
1082
+
1083
+ window.__fcLoadMonaco(function (failed) {
1084
+ if (failed) {
1085
+ $("#fcu-monaco").hide();
1086
+ $("#fcu-fallback").show().val(code);
1087
+ return;
1088
+ }
1089
+ window.__fcApplyJsxDefaults();
1090
+
1091
+ const utilUri = monaco.Uri.parse("file:///fc-util-" + node.id + ".js");
1092
+ const existingModel = monaco.editor.getModel(utilUri);
1093
+ if (existingModel) existingModel.dispose();
1094
+ const utilModel = monaco.editor.createModel(code, "javascript", utilUri);
1095
+
1096
+ utilEditorInstance = monaco.editor.create(
1097
+ document.getElementById("fcu-monaco"),
1098
+ Object.assign({ model: utilModel }, window.__fcEditorOpts),
1099
+ );
1100
+ });
1101
+ },
1102
+
1103
+ /**
1104
+ * Editor → flow: write Monaco contents back to `utilCode`, dispose
1105
+ * model+editor.
1106
+ * @returns {void}
1107
+ */
1108
+ oneditsave: function () {
1109
+ const code = utilEditorInstance
1110
+ ? utilEditorInstance.getValue()
1111
+ : $("#fcu-fallback").val();
1112
+ $("#node-input-utilCode").val(code);
1113
+ this.utilCode = code;
1114
+ if (utilEditorInstance) {
1115
+ utilEditorInstance.getModel().dispose();
1116
+ utilEditorInstance.dispose();
1117
+ utilEditorInstance = null;
1118
+ }
1119
+ },
1120
+
1121
+ /**
1122
+ * Discard Monaco state without writing back.
1123
+ * @returns {void}
1124
+ */
1125
+ oneditcancel: function () {
1126
+ if (utilEditorInstance) {
1127
+ utilEditorInstance.getModel().dispose();
1128
+ utilEditorInstance.dispose();
1129
+ utilEditorInstance = null;
1130
+ }
1131
+ },
1132
+
1133
+ /**
1134
+ * Layout Monaco on dialog resize. -200 px chrome for the utility
1135
+ * dialog (taller because of the symbol-list panel).
1136
+ * @param {{height: number, width: number}} size
1137
+ * @returns {void}
1138
+ */
1139
+ oneditresize: function (size) {
1140
+ let h = size.height - 200;
1141
+ if (h < 150) h = 150;
1142
+ $("#fcu-editor-wrap").css("height", h + "px");
1143
+ if (utilEditorInstance) utilEditorInstance.layout();
1144
+ },
1145
+ });
1146
+ })();
1147
+ </script>
1148
+
1149
+ <script type="text/html" data-help-name="fc-portal-utility">
1150
+ <p>
1151
+ Defines shared <strong>helpers</strong>, <strong>custom hooks</strong> and
1152
+ <strong>constants</strong> available to all <strong>portal-react</strong>
1153
+ nodes. Unlike <code>fc-portal-component</code>, the code is injected raw at
1154
+ top level (no IIFE wrapper) — a single utility node may declare many symbols.
1155
+ </p>
1156
+ <h3>Properties</h3>
1157
+ <dl>
1158
+ <dt>Module name</dt>
1159
+ <dd>
1160
+ Identifier for this utility node. Shares a namespace with
1161
+ <code>fc-portal-component</code> names — must be unique across all
1162
+ components and utilities.
1163
+ </dd>
1164
+ <dt>Code</dt>
1165
+ <dd>
1166
+ Top-level JavaScript: <code>const</code>, <code>let</code>,
1167
+ <code>function</code>, <code>class</code> declarations. <code>import</code>
1168
+ statements at the top are auto-hoisted into the bundle.
1169
+ </dd>
1170
+ </dl>
1171
+ <h3>Selective inclusion</h3>
1172
+ <p>
1173
+ A utility node is bundled into a portal only when the portal's JSX (or any
1174
+ of its referenced library components) mentions at least one of the symbols
1175
+ declared in the utility. Unused utility nodes are skipped.
1176
+ </p>
1177
+ <p>
1178
+ Group related symbols in one node — referencing any one symbol pulls in the
1179
+ whole node's code. Split unrelated helpers across multiple nodes for finer
1180
+ granularity.
1181
+ </p>
1182
+ <h3>Example</h3>
1183
+ <pre>function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
1184
+
1185
+ function useDebounce(value, ms = 300) {
1186
+ const [v, setV] = React.useState(value);
1187
+ React.useEffect(() => {
1188
+ const t = setTimeout(() => setV(value), ms);
1189
+ return () => clearTimeout(t);
1190
+ }, [value, ms]);
1191
+ return v;
1192
+ }</pre>
1193
+ <p>
1194
+ Then in any <strong>portal-react</strong> node:
1195
+ </p>
1196
+ <pre>function App() {
1197
+ const { data } = useNodeRed();
1198
+ const slow = useDebounce(data?.value);
1199
+ return &lt;div&gt;{clamp(slow ?? 0, 0, 100)}&lt;/div&gt;;
1200
+ }</pre>
1201
+ </script>
1202
+
625
1203
  <!-- ============================================================
626
1204
  portal-react – main node
627
1205
  ============================================================ -->
@@ -631,6 +1209,37 @@
631
1209
  <ul id="fc-tabs" style="min-width:600px;margin-bottom:0;"></ul>
632
1210
  </div>
633
1211
  <div id="fc-tabs-content">
1212
+ <!-- ── Tab: JSX ── -->
1213
+ <div id="fc-tab-jsx" class="fc-tab-pane">
1214
+ <div class="form-row node-text-editor-row">
1215
+ <div
1216
+ id="fc-editor-wrap"
1217
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
1218
+ >
1219
+ <div id="fc-monaco" style="width:100%;height:100%;"></div>
1220
+ <textarea
1221
+ id="fc-fallback"
1222
+ style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
1223
+ ></textarea>
1224
+ </div>
1225
+ </div>
1226
+ <div class="form-row" style="display:flex;gap:8px;margin-top:4px;flex-wrap:wrap;">
1227
+ <button type="button" class="red-ui-button" id="fc-btn-starter">
1228
+ <i class="fa fa-magic"></i> Default
1229
+ </button>
1230
+ <button type="button" class="red-ui-button" id="fc-btn-components">
1231
+ <i class="fa fa-cube"></i> Components
1232
+ </button>
1233
+ <button type="button" class="red-ui-button" id="fc-btn-utilities">
1234
+ <i class="fa fa-wrench"></i> Utilities
1235
+ </button>
1236
+ <span style="flex:1"></span>
1237
+ <button type="button" class="red-ui-button" id="fc-btn-preview">
1238
+ <i class="fa fa-eye"></i> Preview
1239
+ </button>
1240
+ </div>
1241
+ </div>
1242
+
634
1243
  <!-- ── Tab: Properties ── -->
635
1244
  <div id="fc-tab-props" class="fc-tab-pane" style="display:none;">
636
1245
  <div class="form-row">
@@ -638,10 +1247,22 @@
638
1247
  <input type="text" id="node-input-name" placeholder="My Portal" />
639
1248
  </div>
640
1249
  <div class="form-row">
641
- <label for="node-input-endpoint"
642
- ><i class="fa fa-globe"></i> Endpoint</label
1250
+ <label for="node-input-subPath"
1251
+ ><i class="fa fa-globe"></i> Sub-path</label
643
1252
  >
644
- <input type="text" id="node-input-endpoint" placeholder="/portal" />
1253
+ <div style="display:inline-flex;align-items:stretch;width:70%;">
1254
+ <span
1255
+ style="display:inline-flex;align-items:center;padding:0 8px;background:var(--red-ui-secondary-background,#f3f3f3);border:1px solid var(--red-ui-form-input-border-color,#ccc);border-right:none;border-radius:4px 0 0 4px;color:var(--red-ui-secondary-text-color,#888);font-family:monospace;user-select:none;"
1256
+ >/fromcubes/</span
1257
+ >
1258
+ <input
1259
+ type="text"
1260
+ id="node-input-subPath"
1261
+ placeholder="sensors"
1262
+ style="flex:1;border-radius:0 4px 4px 0;"
1263
+ />
1264
+ </div>
1265
+ <input type="hidden" id="node-input-endpoint" />
645
1266
  <div
646
1267
  style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
647
1268
  id="fc-url-hint"
@@ -653,41 +1274,41 @@
653
1274
  >
654
1275
  <input type="text" id="node-input-pageTitle" placeholder="Portal" />
655
1276
  </div>
656
- </div>
657
-
658
- <!-- ── Tab: JSX ── -->
659
- <div id="fc-tab-jsx" class="fc-tab-pane">
660
- <div class="form-row" style="margin-bottom:0;">
661
- <div style="font-size:11px;opacity:.6;margin-bottom:4px;">
662
- <code>useNodeRed()</code> &rarr;
663
- <code>{ data, send }</code> &nbsp;|&nbsp; Components from
664
- <code>fc-portal-component</code> nodes auto-imported &nbsp;|&nbsp;
665
- Must export <code>&lt;App /&gt;</code> &nbsp;|&nbsp;
666
- <strong>Transpiled server-side at deploy</strong>
667
- </div>
668
- </div>
669
- <div class="form-row node-text-editor-row">
1277
+ <div class="form-row node-input-libs-container-row">
1278
+ <label style="width:auto;"><i class="fa fa-archive"></i> Modules</label>
1279
+ <ol id="node-input-libs-container"></ol>
670
1280
  <div
671
- id="fc-editor-wrap"
672
- style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
1281
+ style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;"
673
1282
  >
674
- <div id="fc-monaco" style="width:100%;height:100%;"></div>
675
- <textarea
676
- id="fc-fallback"
677
- style="display:none;width:100%;height:100%;font-family:'Cascadia Code',Consolas,monospace;font-size:13px;background:#1e1e1e;color:#d4d4d4;border:none;padding:12px;resize:none;"
678
- ></textarea>
1283
+ npm packages to bundle. Node-RED auto-installs them at deploy time.
679
1284
  </div>
680
1285
  </div>
681
- <div class="form-row" style="display:flex;gap:8px;margin-top:4px;">
682
- <button type="button" class="red-ui-button" id="fc-btn-preview">
683
- <i class="fa fa-eye"></i> Preview
684
- </button>
685
- <button type="button" class="red-ui-button" id="fc-btn-components">
686
- <i class="fa fa-cube"></i> Components
687
- </button>
688
- <button type="button" class="red-ui-button" id="fc-btn-starter">
689
- <i class="fa fa-magic"></i> Starter
690
- </button>
1286
+ <div class="form-row" style="margin-top:12px;">
1287
+ <label style="width:auto;">
1288
+ <input
1289
+ type="checkbox"
1290
+ id="node-input-portalAuth"
1291
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
1292
+ />
1293
+ Enable Portal Auth headers
1294
+ </label>
1295
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
1296
+ Read <code>X-Portal-*</code> headers from reverse proxy.
1297
+ </div>
1298
+ </div>
1299
+ <div class="form-row" style="margin-top:12px;">
1300
+ <label style="width:auto;">
1301
+ <input
1302
+ type="checkbox"
1303
+ id="node-input-showWsStatus"
1304
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
1305
+ />
1306
+ Show WebSocket status indicator
1307
+ </label>
1308
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
1309
+ Displays a small <em>fromcubes</em> badge in the bottom-right corner
1310
+ showing connection state.
1311
+ </div>
691
1312
  </div>
692
1313
  </div>
693
1314
 
@@ -696,7 +1317,8 @@
696
1317
  <div class="form-row">
697
1318
  <div style="font-size:11px;opacity:.6;margin-bottom:6px;">
698
1319
  Extra tags injected into <code>&lt;head&gt;</code>. CDN links, fonts,
699
- stylesheets, meta tags.
1320
+ stylesheets, meta tags. Trusted authors only: scripts and event
1321
+ handlers run in the public portal page.
700
1322
  </div>
701
1323
  <div
702
1324
  id="fc-head-wrap"
@@ -719,11 +1341,11 @@
719
1341
 
720
1342
  <script type="text/javascript">
721
1343
  (function () {
722
- var editorInstance = null;
723
- var headEditorInstance = null;
1344
+ let editorInstance = null;
1345
+ let headEditorInstance = null;
724
1346
 
725
- var STARTER = [
726
- "// useNodeRed() \u2192 { data, send }",
1347
+ const STARTER = [
1348
+ "// useNodeRed() \u2192 { data, send, portalClient }",
727
1349
  "// data = last msg.payload from input wire",
728
1350
  "// send(payload, topic?) = push msg to output wire",
729
1351
  "// Components from fc-portal-component nodes are available by name.",
@@ -734,7 +1356,7 @@
734
1356
  "",
735
1357
  " return (",
736
1358
  ' <div className="min-h-screen bg-zinc-950 p-8">',
737
- ' <h1 className="text-2xl font-light text-cyan-400 mb-6">Portal</h1>',
1359
+ ' <h1 className="text-2xl font-light text-cyan-400 mb-6">fromcubes</h1>',
738
1360
  ' <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">',
739
1361
  ' <StatusCard label="Value" value={d.value ?? "—"} unit="" />',
740
1362
  " </div>",
@@ -754,37 +1376,94 @@
754
1376
  color: "#61dafb",
755
1377
  defaults: {
756
1378
  name: { value: "" },
757
- endpoint: { value: "/portal", required: true },
758
- pageTitle: { value: "Portal" },
759
- componentCode: { value: STARTER },
1379
+ subPath: {
1380
+ value: "",
1381
+ required: true,
1382
+ validate: function (v) {
1383
+ if (typeof v !== "string") return false;
1384
+ const t = v.trim();
1385
+ if (t.length === 0) return false;
1386
+ if (/\s/.test(t)) return false;
1387
+ if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
1388
+ const segs = t.split("/");
1389
+ for (let i = 0; i < segs.length; i++) {
1390
+ const s = segs[i];
1391
+ if (!s || s === "." || s === "..") return false;
1392
+ const lower = s.toLowerCase();
1393
+ if (lower === "public" || lower === "_ws") return false;
1394
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
1395
+ }
1396
+ return true;
1397
+ },
1398
+ },
1399
+ endpoint: { value: "" },
1400
+ pageTitle: { value: "fromcubes" },
1401
+ componentCode: {
1402
+ value: STARTER,
1403
+ validate: function (v) {
1404
+ return typeof v === "string" && v.trim().length > 0;
1405
+ },
1406
+ },
760
1407
  customHead: { value: "" },
1408
+ portalAuth: { value: false },
1409
+ showWsStatus: { value: false },
1410
+ libs: { value: [] },
761
1411
  },
762
1412
  inputs: 1,
763
1413
  outputs: 1,
764
1414
  icon: "font-awesome/fa-desktop",
765
- paletteLabel: "portal react",
1415
+ paletteLabel: "React Portal",
1416
+ // Source AND sink — we keep the default left-aligned input even though
1417
+ // Node-RED appearance docs suggest align: 'right' for nodes that sit at
1418
+ // the end of a flow. This portal is hybrid: the input wire pushes msgs
1419
+ // into the browser, while the output wire forwards UI events out. Left
1420
+ // alignment matches the dominant "msg → render" direction.
1421
+ inputLabels: ["broadcast / unicast msg → page"],
1422
+ outputLabels: ["UI event from page"],
1423
+ /**
1424
+ * Canvas label — shows `name` or the deployed URL (`/fromcubes/<subPath>`).
1425
+ * @returns {string}
1426
+ */
766
1427
  label: function () {
767
- return this.name || this.endpoint || "portal react";
1428
+ return this.name || ("/fromcubes/" + (this.subPath || "?"));
768
1429
  },
769
1430
 
1431
+ /**
1432
+ * Editor lifecycle for the portal node. Heaviest of the three —
1433
+ * creates two Monaco editors (JSX + Head HTML), wires up:
1434
+ * - URL hint that previews `/fromcubes/<subPath>`
1435
+ * - Legacy `endpoint` field migration warning
1436
+ * - `RED.tabs` for JSX / Properties / Head HTML panels
1437
+ * - Component picker dialog (`#fc-btn-components`)
1438
+ * - Utility picker dialog (`#fc-btn-utilities`)
1439
+ * - Portal Assets sidebar tab via `RED.sidebar.addTab` (once)
1440
+ * @returns {void}
1441
+ */
770
1442
  oneditprepare: function () {
771
- var node = this;
772
- var code = node.componentCode || STARTER;
773
- var headCode = node.customHead || "";
1443
+ const node = this;
1444
+ const code =
1445
+ typeof node.componentCode === "string" ? node.componentCode : STARTER;
1446
+ const headCode = node.customHead || "";
774
1447
  console.log("[FC-Monaco] PORTAL oneditprepare, node.id=" + node.id);
775
1448
 
776
1449
  if (!window.__fcTwClasses) {
777
1450
  console.log("[FC-Monaco] loading tw-classes...");
778
1451
  $.getJSON("portal-react/tw-classes", function (classes) {
779
- console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
1452
+ console.log(
1453
+ "[FC-Monaco] tw-classes loaded, count=" + classes.length,
1454
+ );
780
1455
  window.__fcTwClasses = classes;
781
1456
  }).fail(function (xhr) {
782
- console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
1457
+ console.error(
1458
+ "[FC-Monaco] tw-classes FAILED:",
1459
+ xhr.status,
1460
+ xhr.statusText,
1461
+ );
783
1462
  });
784
1463
  }
785
1464
 
786
1465
  // Tabs
787
- var fcTabs = RED.tabs.create({
1466
+ const fcTabs = RED.tabs.create({
788
1467
  id: "fc-tabs",
789
1468
  onchange: function (tab) {
790
1469
  $(".fc-tab-pane").hide();
@@ -800,18 +1479,73 @@
800
1479
  fcTabs.addTab({ id: "fc-tab-head", label: "Head HTML" });
801
1480
  fcTabs.activateTab("fc-tab-jsx");
802
1481
 
1482
+ // Legacy endpoint detection + convenience pre-fill
1483
+ if (node.endpoint && typeof node.endpoint === "string") {
1484
+ const legacy = node.endpoint;
1485
+ const prefix = "/fromcubes/";
1486
+ if (legacy.indexOf(prefix) === 0) {
1487
+ if (!node.subPath) {
1488
+ $("#node-input-subPath").val(legacy.slice(prefix.length));
1489
+ }
1490
+ } else if (legacy !== "" && legacy !== "/fromcubes") {
1491
+ $("#fc-url-hint")
1492
+ .css("color", "#c00")
1493
+ .text(
1494
+ "Legacy endpoint detected: '" +
1495
+ legacy +
1496
+ "'. Set a Sub-path and redeploy.",
1497
+ );
1498
+ }
1499
+ }
1500
+
803
1501
  // URL hint
804
1502
  function updateHint() {
805
- var ep = $("#node-input-endpoint").val() || "/portal";
806
- $("#fc-url-hint").text("Page served at: http://<host>:1880" + ep);
1503
+ const sp = ($("#node-input-subPath").val() || "").trim();
1504
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1505
+ if (sp) {
1506
+ $("#fc-url-hint")
1507
+ .css("color", "")
1508
+ .text(
1509
+ "Page served at: http://<host>:1880" +
1510
+ root +
1511
+ "/fromcubes/" +
1512
+ sp,
1513
+ );
1514
+ } else if (!node.endpoint || node.endpoint.indexOf("/fromcubes/") === 0) {
1515
+ $("#fc-url-hint")
1516
+ .css("color", "")
1517
+ .text("Sub-path required (will be served under /fromcubes/<sub-path>)");
1518
+ }
807
1519
  }
808
- $("#node-input-endpoint").on("input", updateHint);
1520
+ $("#node-input-subPath").on("input", updateHint);
809
1521
  updateHint();
810
1522
 
1523
+ // Modules editableList (like function node's libs)
1524
+ const libsList = node.libs || [];
1525
+ $("#node-input-libs-container").css("min-height","68px").editableList({
1526
+ addItem: function(container, i, opt) {
1527
+ const lib = opt || {};
1528
+ const row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1529
+ const modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1530
+ const varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1531
+ modInput.val(lib.module || "");
1532
+ varInput.val(lib.const || "");
1533
+ container.data("mod", modInput);
1534
+ container.data("var", varInput);
1535
+ },
1536
+ removable: true,
1537
+ sortable: true
1538
+ });
1539
+ libsList.forEach(function(lib) {
1540
+ $("#node-input-libs-container").editableList("addItem", lib);
1541
+ });
1542
+
811
1543
  // Monaco
812
1544
  window.__fcLoadMonaco(function (failed) {
813
1545
  if (failed) {
814
- console.error("[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea");
1546
+ console.error(
1547
+ "[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea",
1548
+ );
815
1549
  $("#fc-monaco").hide();
816
1550
  $("#fc-fallback").show().val(code);
817
1551
  $("#fc-head-monaco").hide();
@@ -819,30 +1553,40 @@
819
1553
  return;
820
1554
  }
821
1555
 
822
- console.log("[FC-Monaco] PORTAL: applying JSX defaults before model creation");
1556
+ console.log(
1557
+ "[FC-Monaco] PORTAL: applying JSX defaults before model creation",
1558
+ );
823
1559
  window.__fcApplyJsxDefaults();
824
- var opts = window.__fcEditorOpts;
1560
+ const opts = window.__fcEditorOpts;
825
1561
 
826
- var jsxUri = monaco.Uri.parse("file:///fc-portal-" + node.id + ".jsx");
1562
+ const jsxUri = monaco.Uri.parse(
1563
+ "file:///fc-portal-" + node.id + ".jsx",
1564
+ );
827
1565
  console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
828
- var existingJsx = monaco.editor.getModel(jsxUri);
1566
+ const existingJsx = monaco.editor.getModel(jsxUri);
829
1567
  if (existingJsx) {
830
1568
  console.log("[FC-Monaco] PORTAL: disposing existing JSX model");
831
1569
  existingJsx.dispose();
832
1570
  }
833
- var jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
834
- console.log("[FC-Monaco] PORTAL: JSX model created, language=" + jsxModel.getLanguageId());
1571
+ const jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
1572
+ console.log(
1573
+ "[FC-Monaco] PORTAL: JSX model created, language=" +
1574
+ jsxModel.getLanguageId(),
1575
+ );
835
1576
  editorInstance = monaco.editor.create(
836
1577
  document.getElementById("fc-monaco"),
837
1578
  Object.assign({ model: jsxModel }, opts),
838
1579
  );
839
1580
  console.log("[FC-Monaco] PORTAL: JSX editor created");
840
- if (window.__fcAttachSelfClose) window.__fcAttachSelfClose(editorInstance);
1581
+ if (window.__fcAttachSelfClose)
1582
+ window.__fcAttachSelfClose(editorInstance);
841
1583
 
842
- var headUri = monaco.Uri.parse("file:///fc-head-" + node.id + ".html");
843
- var existingHead = monaco.editor.getModel(headUri);
1584
+ const headUri = monaco.Uri.parse(
1585
+ "file:///fc-head-" + node.id + ".html",
1586
+ );
1587
+ const existingHead = monaco.editor.getModel(headUri);
844
1588
  if (existingHead) existingHead.dispose();
845
- var headModel = monaco.editor.createModel(headCode, "html", headUri);
1589
+ const headModel = monaco.editor.createModel(headCode, "html", headUri);
846
1590
  headEditorInstance = monaco.editor.create(
847
1591
  document.getElementById("fc-head-monaco"),
848
1592
  Object.assign({ model: headModel }, opts),
@@ -851,93 +1595,320 @@
851
1595
 
852
1596
  // Log markers after a short delay (diagnostics are async)
853
1597
  setTimeout(function () {
854
- var markers = monaco.editor.getModelMarkers({ resource: jsxUri });
855
- console.log("[FC-Monaco] PORTAL: markers after 500ms, count=" + markers.length);
1598
+ const markers = monaco.editor.getModelMarkers({ resource: jsxUri });
1599
+ console.log(
1600
+ "[FC-Monaco] PORTAL: markers after 500ms, count=" +
1601
+ markers.length,
1602
+ );
856
1603
  markers.forEach(function (m) {
857
- console.log("[FC-Monaco] PORTAL marker: code=" + m.code + " severity=" + m.severity + " msg=" + m.message);
1604
+ console.log(
1605
+ "[FC-Monaco] PORTAL marker: code=" +
1606
+ m.code +
1607
+ " severity=" +
1608
+ m.severity +
1609
+ " msg=" +
1610
+ m.message,
1611
+ );
858
1612
  });
859
- console.log("[FC-Monaco] PORTAL: current compilerOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getCompilerOptions()));
860
- console.log("[FC-Monaco] PORTAL: current diagnosticsOptions:", JSON.stringify(monaco.typescript.javascriptDefaults.getDiagnosticsOptions()));
1613
+ console.log(
1614
+ "[FC-Monaco] PORTAL: current compilerOptions:",
1615
+ JSON.stringify(
1616
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
1617
+ ),
1618
+ );
1619
+ console.log(
1620
+ "[FC-Monaco] PORTAL: current diagnosticsOptions:",
1621
+ JSON.stringify(
1622
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
1623
+ ),
1624
+ );
861
1625
  }, 500);
862
1626
  });
863
1627
 
864
1628
  // Buttons
865
1629
  $("#fc-btn-starter").on("click", function () {
866
- if (editorInstance) editorInstance.setValue(STARTER);
867
- else $("#fc-fallback").val(STARTER);
1630
+ $("<div>Replace current JSX with default starter code?</div>").dialog({
1631
+ title: "Load Default",
1632
+ modal: true,
1633
+ width: 360,
1634
+ buttons: [
1635
+ { text: "Cancel", click: function () { $(this).dialog("close"); } },
1636
+ { text: "Replace", class: "primary", click: function () {
1637
+ if (editorInstance) editorInstance.setValue(STARTER);
1638
+ else $("#fc-fallback").val(STARTER);
1639
+ $(this).dialog("close");
1640
+ }},
1641
+ ],
1642
+ close: function () { $(this).remove(); },
1643
+ });
868
1644
  });
869
1645
 
870
1646
  $("#fc-btn-preview").on("click", function () {
871
- var ep = $("#node-input-endpoint").val();
872
- if (ep) window.open(ep, "_blank");
1647
+ const sp = ($("#node-input-subPath").val() || "").trim();
1648
+ const root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1649
+ if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
873
1650
  });
874
1651
 
875
1652
  $("#fc-btn-components").on("click", function () {
876
1653
  $.getJSON("portal-react/registry", function (reg) {
877
- var names = Object.keys(reg);
1654
+ const names = Object.keys(reg).sort();
878
1655
  if (!names.length) {
879
1656
  RED.notify("No component nodes on canvas.", "warning");
880
1657
  return;
881
1658
  }
882
- var html = "<p>Click to insert:</p>";
1659
+
1660
+ function extractProps(code) {
1661
+ if (!code) return [];
1662
+ const m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1663
+ if (!m) return [];
1664
+ return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
1665
+ }
1666
+
1667
+ let html =
1668
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1669
+ '<input type="text" id="fc-comp-search" placeholder="Search..." ' +
1670
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
1671
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
1672
+ '<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
1673
+
883
1674
  names.forEach(function (n) {
884
- var c = reg[n];
1675
+ const c = reg[n];
1676
+ const props = extractProps(c.code);
1677
+ const detailParts = [];
1678
+ if (props.length) {
1679
+ detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
1680
+ props.map(function (p) {
1681
+ return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
1682
+ }).join("") + '</div>');
1683
+ }
1684
+ const hasDetail = detailParts.length > 0;
1685
+
885
1686
  html +=
886
- '<div style="padding:4px 0;border-bottom:1px solid rgba(128,128,128,.2)">' +
887
- '<button class="red-ui-button fc-lib-ins" data-name="' +
888
- n +
889
- '">' +
890
- n +
891
- "</button>" +
892
- ' <span style="opacity:.5;font-size:11px">in:[' +
893
- (c.inputs || []).join(",") +
894
- "] out:[" +
895
- (c.outputs || []).join(",") +
896
- "]</span></div>";
1687
+ '<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
1688
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
1689
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-comp-row">' +
1690
+ (hasDetail ? '<i class="fa fa-caret-right fc-comp-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' : '<span style="width:14px;"></span>') +
1691
+ '<span class="fc-comp-name" data-name="' + n + '" style="font-weight:600;font-size:12px;flex:1;">' + n + '</span>' +
1692
+ '</div>' +
1693
+ (hasDetail ? '<div class="fc-comp-detail" style="display:none;padding:0 6px 4px 20px;">' + detailParts.join("") + '</div>' : '') +
1694
+ '</div>';
897
1695
  });
898
- $("<div></div>")
899
- .html(html)
900
- .dialog({
901
- title: "Available Components",
902
- modal: true,
903
- width: 400,
904
- buttons: [
905
- {
906
- text: "Close",
907
- click: function () {
908
- $(this).dialog("close");
909
- },
910
- },
911
- ],
912
- close: function () {
913
- $(this).remove();
914
- },
1696
+ html += '</div></div>';
1697
+
1698
+ const $dlg = $("<div></div>").html(html).dialog({
1699
+ title: "Components",
1700
+ modal: true,
1701
+ width: 380,
1702
+ buttons: [
1703
+ { text: "Close", click: function () { $(this).dialog("close"); } },
1704
+ ],
1705
+ close: function () { $(this).remove(); },
1706
+ });
1707
+
1708
+ // Search
1709
+ $dlg.find("#fc-comp-search").on("input", function () {
1710
+ const q = $(this).val().toLowerCase();
1711
+ $dlg.find(".fc-comp-item").each(function () {
1712
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
1713
+ });
1714
+ }).focus();
1715
+
1716
+ // Hover
1717
+ $dlg.on("mouseenter", ".fc-comp-row", function () {
1718
+ $(this).css("background", "rgba(128,128,128,.1)");
1719
+ }).on("mouseleave", ".fc-comp-row", function () {
1720
+ $(this).css("background", "");
1721
+ });
1722
+
1723
+ // Arrow toggle detail
1724
+ $dlg.on("click", ".fc-comp-arrow", function (e) {
1725
+ e.stopPropagation();
1726
+ const $item = $(this).closest(".fc-comp-item");
1727
+ const $detail = $item.find(".fc-comp-detail");
1728
+ const open = $detail.is(":visible");
1729
+ $detail.slideToggle(100);
1730
+ $(this).css("transform", open ? "" : "rotate(90deg)");
1731
+ });
1732
+
1733
+ // Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
1734
+ $dlg.on("click", ".fc-comp-name", function () {
1735
+ const name = $(this).data("name");
1736
+ const openTag = "<" + name + ">";
1737
+ const closeTag = "</" + name + ">";
1738
+ const text = openTag + closeTag;
1739
+ $dlg.dialog("close");
1740
+ if (editorInstance) {
1741
+ const sel = editorInstance.getSelection();
1742
+ const startLine = sel.startLineNumber;
1743
+ const startCol = sel.startColumn;
1744
+ editorInstance.executeEdits("fc-components", [{
1745
+ range: sel,
1746
+ text: text,
1747
+ }]);
1748
+ setTimeout(function () {
1749
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + openTag.length });
1750
+ editorInstance.focus();
1751
+ }, 50);
1752
+ } else {
1753
+ const ta = $("#fc-fallback")[0];
1754
+ const s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1755
+ ta.value = v.slice(0, s) + text + v.slice(e);
1756
+ setTimeout(function () {
1757
+ ta.selectionStart = ta.selectionEnd = s + openTag.length;
1758
+ ta.focus();
1759
+ }, 50);
1760
+ }
1761
+ });
1762
+ });
1763
+ });
1764
+
1765
+ $("#fc-btn-utilities").on("click", function () {
1766
+ $.getJSON("portal-react/utilities", function (reg) {
1767
+ // Refresh global cache too so completion provider picks up changes
1768
+ if (window.__fcRefreshUtilities) window.__fcRefreshUtilities();
1769
+ const names = Object.keys(reg).sort();
1770
+ if (!names.length) {
1771
+ RED.notify("No utility nodes on canvas.", "warning");
1772
+ return;
1773
+ }
1774
+
1775
+ let html =
1776
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1777
+ '<input type="text" id="fc-util-search" placeholder="Search..." ' +
1778
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
1779
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
1780
+ '<div id="fc-util-list" style="flex:1;overflow-y:auto;">';
1781
+
1782
+ names.forEach(function (n) {
1783
+ const u = reg[n];
1784
+ const syms = u.symbols || [];
1785
+ const errBadge = u.error
1786
+ ? '<span style="margin-left:6px;font-size:10px;color:#ef4444;" title="' +
1787
+ String(u.error).replace(/"/g, "&quot;") + '">syntax error</span>'
1788
+ : "";
1789
+ const symBadges = syms.map(function (s) {
1790
+ return '<span class="fc-util-sym" data-sym="' + s +
1791
+ '" style="display:inline-block;background:rgba(251,191,36,.15);border:1px solid rgba(251,191,36,.35);' +
1792
+ 'padding:1px 6px;border-radius:3px;margin:2px 4px 2px 0;font-size:11px;font-family:monospace;cursor:pointer;">' +
1793
+ s + '</span>';
1794
+ }).join("");
1795
+ const detail = syms.length
1796
+ ? '<div style="margin:4px 0 0 0;">' + symBadges + '</div>'
1797
+ : '<div style="margin:4px 0 0 0;font-size:11px;opacity:.5;">(no top-level symbols detected)</div>';
1798
+ const searchKey = (n + " " + syms.join(" ")).toLowerCase();
1799
+
1800
+ html +=
1801
+ '<div class="fc-util-item" data-name="' + n + '" data-search="' + searchKey + '" ' +
1802
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
1803
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-util-row">' +
1804
+ '<i class="fa fa-caret-right fc-util-arrow" style="width:14px;opacity:.4;font-size:12px;transition:transform .15s;"></i>' +
1805
+ '<i class="fa fa-wrench" style="width:14px;color:#fbbf24;font-size:11px;"></i>' +
1806
+ '<span style="font-weight:600;font-size:12px;flex:1;margin-left:4px;">' + n + '</span>' +
1807
+ errBadge +
1808
+ '</div>' +
1809
+ '<div class="fc-util-detail" style="display:none;padding:0 6px 6px 32px;">' + detail + '</div>' +
1810
+ '</div>';
1811
+ });
1812
+ html += '</div></div>';
1813
+
1814
+ const $dlg = $("<div></div>").html(html).dialog({
1815
+ title: "Utilities",
1816
+ modal: true,
1817
+ width: 420,
1818
+ buttons: [
1819
+ { text: "Close", click: function () { $(this).dialog("close"); } },
1820
+ ],
1821
+ close: function () { $(this).remove(); },
1822
+ });
1823
+
1824
+ $dlg.find("#fc-util-search").on("input", function () {
1825
+ const q = $(this).val().toLowerCase();
1826
+ $dlg.find(".fc-util-item").each(function () {
1827
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
915
1828
  });
916
- $(document).on("click", ".fc-lib-ins", function () {
917
- var tag = "<" + $(this).data("name") + " />";
1829
+ }).focus();
1830
+
1831
+ $dlg.on("mouseenter", ".fc-util-row", function () {
1832
+ $(this).css("background", "rgba(251,191,36,.08)");
1833
+ }).on("mouseleave", ".fc-util-row", function () {
1834
+ $(this).css("background", "");
1835
+ });
1836
+
1837
+ $dlg.on("click", ".fc-util-row", function (e) {
1838
+ if ($(e.target).closest(".fc-util-sym").length) return;
1839
+ const $item = $(this).closest(".fc-util-item");
1840
+ const $detail = $item.find(".fc-util-detail");
1841
+ const $arrow = $item.find(".fc-util-arrow");
1842
+ const open = $detail.is(":visible");
1843
+ $detail.slideToggle(100);
1844
+ $arrow.css("transform", open ? "" : "rotate(90deg)");
1845
+ });
1846
+
1847
+ // Click symbol → insert bare identifier at cursor
1848
+ $dlg.on("click", ".fc-util-sym", function (e) {
1849
+ e.stopPropagation();
1850
+ const sym = $(this).data("sym");
1851
+ $dlg.dialog("close");
918
1852
  if (editorInstance) {
919
- editorInstance.trigger("keyboard", "type", { text: tag });
920
- editorInstance.focus();
1853
+ const sel = editorInstance.getSelection();
1854
+ const startLine = sel.startLineNumber;
1855
+ const startCol = sel.startColumn;
1856
+ editorInstance.executeEdits("fc-utilities", [{
1857
+ range: sel,
1858
+ text: sym,
1859
+ }]);
1860
+ setTimeout(function () {
1861
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + sym.length });
1862
+ editorInstance.focus();
1863
+ }, 50);
921
1864
  } else {
922
- var ta = $("#fc-fallback")[0];
923
- var p = ta.selectionStart,
924
- v = ta.value;
925
- ta.value = v.slice(0, p) + tag + v.slice(p);
1865
+ const ta = $("#fc-fallback")[0];
1866
+ const s = ta.selectionStart, en = ta.selectionEnd, v = ta.value;
1867
+ ta.value = v.slice(0, s) + sym + v.slice(en);
1868
+ setTimeout(function () {
1869
+ ta.selectionStart = ta.selectionEnd = s + sym.length;
1870
+ ta.focus();
1871
+ }, 50);
926
1872
  }
927
1873
  });
928
1874
  });
929
1875
  });
930
1876
  },
931
1877
 
1878
+ /**
1879
+ * Editor → flow. Normalises `subPath` (trim), clears the legacy
1880
+ * `endpoint` field, collects `libs` from the editableList widget,
1881
+ * writes both Monaco editors back to hidden inputs + node props,
1882
+ * then disposes both editors.
1883
+ * @returns {void}
1884
+ */
932
1885
  oneditsave: function () {
933
1886
  console.log("[FC-Monaco] PORTAL oneditsave");
934
- var code = editorInstance
1887
+
1888
+ // Clear legacy endpoint field + normalize subPath
1889
+ $("#node-input-endpoint").val("");
1890
+ this.endpoint = "";
1891
+ this.subPath = ($("#node-input-subPath").val() || "").trim();
1892
+
1893
+ // Collect libs from editableList
1894
+ const libs = [];
1895
+ const items = $("#node-input-libs-container").editableList("items");
1896
+ items.each(function() {
1897
+ const mod = $(this).data("mod").val().trim();
1898
+ const v = $(this).data("var").val().trim();
1899
+ if (mod) {
1900
+ libs.push({ module: mod, var: v });
1901
+ }
1902
+ });
1903
+ this.libs = libs;
1904
+
1905
+ const code = editorInstance
935
1906
  ? editorInstance.getValue()
936
1907
  : $("#fc-fallback").val();
937
1908
  $("#node-input-componentCode").val(code);
938
1909
  this.componentCode = code;
939
1910
 
940
- var head = headEditorInstance
1911
+ const head = headEditorInstance
941
1912
  ? headEditorInstance.getValue()
942
1913
  : $("#fc-head-fallback").val();
943
1914
  $("#node-input-customHead").val(head);
@@ -957,6 +1928,12 @@
957
1928
  }
958
1929
  },
959
1930
 
1931
+ /**
1932
+ * Discard both Monaco editors without writing back to the node.
1933
+ * Identical dispose pattern to oneditsave so we never leak Monaco
1934
+ * graphs even if the user spams Edit / Cancel rapidly.
1935
+ * @returns {void}
1936
+ */
960
1937
  oneditcancel: function () {
961
1938
  console.log("[FC-Monaco] PORTAL oneditcancel");
962
1939
  if (editorInstance) {
@@ -973,12 +1950,19 @@
973
1950
  }
974
1951
  },
975
1952
 
1953
+ /**
1954
+ * Dialog resize → compute available editor height by subtracting
1955
+ * the heights of non-tab form rows + tab strip + 30 px padding,
1956
+ * then re-layout both Monaco editors. Falls back to 200 px minimum.
1957
+ * @param {{height: number, width: number}} size
1958
+ * @returns {void}
1959
+ */
976
1960
  oneditresize: function (size) {
977
- var tabsH = $("#fc-tabs").outerHeight(true) || 0;
978
- var rows = $(
1961
+ const tabsH = $("#fc-tabs").outerHeight(true) || 0;
1962
+ const rows = $(
979
1963
  "#dialog-form>div:not(#fc-tabs-content):not(:has(#fc-tabs))",
980
1964
  );
981
- var h = size.height;
1965
+ let h = size.height;
982
1966
  rows.each(function () {
983
1967
  h -= $(this).outerHeight(true);
984
1968
  });
@@ -997,37 +1981,59 @@
997
1981
  Help
998
1982
  ============================================================ -->
999
1983
  <script type="text/html" data-help-name="portal-react">
1984
+ <h3>Quick Reference</h3>
1985
+ <ul>
1986
+ <li><code>useNodeRed()</code> &rarr; <code>{ data, send, user, portalClient }</code></li>
1987
+ <li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
1988
+ <li>Must export <code>&lt;App /&gt;</code></li>
1989
+ <li>JSX is transpiled server-side at deploy</li>
1990
+ </ul>
1991
+
1000
1992
  <p>
1001
- Renders a React application on a configurable HTTP endpoint with live
1002
- WebSocket data binding.
1003
- </p>
1004
- <h3>Key difference from Dashboard 2.0</h3>
1005
- <p>
1006
- JSX is transpiled <strong>server-side at deploy time</strong> using Sucrase.
1007
- The browser receives plain JS — no Babel, no runtime compilation. Page
1008
- weight: ~45 KB (React production).
1993
+ Renders a React 19 application on a configurable HTTP endpoint with live
1994
+ WebSocket data binding. JSX is transpiled <strong>server-side at deploy
1995
+ time</strong> using esbuild — browsers receive pre-compiled JS with zero
1996
+ runtime compilation. Tailwind CSS 4 utility classes are generated
1997
+ server-side and served as static CSS.
1009
1998
  </p>
1010
1999
 
1011
2000
  <h3>Inputs</h3>
1012
2001
  <p>
1013
- <code>msg.payload</code> is pushed to all connected clients via WebSocket.
2002
+ <code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
2003
+ to target specific clients, or omit it to broadcast to all.
1014
2004
  </p>
1015
2005
  <pre>
1016
- const { data, send } = useNodeRed();
1017
- // data = last msg.payload</pre
2006
+ const { data, send, user, portalClient } = useNodeRed();
2007
+ // data = last msg.payload (reactive)
2008
+ // send(payload, topic?) — emit msg on output wire
2009
+ // user = portal auth data or null
2010
+ // portalClient = unique session/tab ID (assigned by server)</pre
1018
2011
  >
1019
2012
 
1020
2013
  <h3>Outputs</h3>
1021
2014
  <p>
1022
2015
  <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
1023
- output wire.
2016
+ output wire via WebSocket.
2017
+ </p>
2018
+
2019
+ <h3>npm Packages</h3>
2020
+ <p>
2021
+ Add npm packages in the <strong>Modules</strong> list (e.g.
2022
+ <code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
2023
+ Node-RED auto-installs them at deploy time. All packages are bundled with
2024
+ React into a single vendor IIFE via esbuild, cached by hash of installed
2025
+ versions. Use them in JSX via standard imports.
1024
2026
  </p>
1025
2027
 
1026
2028
  <h3>Deploy behavior</h3>
1027
2029
  <ul>
1028
2030
  <li>
1029
- Each deploy re-transpiles JSX (cached by content hash unchanged code is
1030
- instant)
2031
+ Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
2032
+ (both cached by content hash — unchanged code is instant)
2033
+ </li>
2034
+ <li>
2035
+ Vendor bundle (React + npm packages) rebuilt only when package versions
2036
+ change
1031
2037
  </li>
1032
2038
  <li>
1033
2039
  Active WebSocket clients receive close code 1001 and auto-reconnect
@@ -1045,15 +2051,584 @@ const { data, send } = useNodeRed();
1045
2051
  <p>
1046
2052
  Components defined in <strong>fc-portal-component</strong> nodes on the
1047
2053
  canvas are auto-injected into every portal-react page. Use them as JSX tags
1048
- by their component name.
2054
+ by their component name — no imports needed.
1049
2055
  </p>
1050
2056
 
2057
+ <h3>Portal Auth</h3>
2058
+ <p>
2059
+ When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
2060
+ a reverse proxy (e.g. Nginx) and exposes user data:
2061
+ </p>
2062
+ <ul>
2063
+ <li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
2064
+ <li>
2065
+ All WebSocket messages include <code>msg._client</code> with
2066
+ <code>{ portalClient, ...userFields }</code>
2067
+ </li>
2068
+ <li>
2069
+ To target a specific tab: keep <code>msg._client</code> (or set
2070
+ <code>msg._client = { portalClient: "..." }</code>)
2071
+ </li>
2072
+ <li>
2073
+ To target all tabs of a user: set
2074
+ <code>msg._client = { userId: "..." }</code> (no portalClient)
2075
+ </li>
2076
+ <li>
2077
+ To broadcast to all: remove <code>msg._client</code>
2078
+ </li>
2079
+ </ul>
2080
+
1051
2081
  <h3>Custom Head HTML</h3>
1052
2082
  <p>
1053
2083
  Inject CDN links, fonts, or extra stylesheets into
1054
- <code>&lt;head&gt;</code>. Example:
2084
+ <code>&lt;head&gt;</code>. This is raw trusted-author HTML; scripts and
2085
+ event handlers run in the public portal page. Example:
1055
2086
  </p>
1056
2087
  <pre>
1057
- &lt;link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"&gt;</pre
2088
+ &lt;link href="https://fonts.googleapis.com/css2?family=Inter&amp;display=swap" rel="stylesheet"&gt;</pre
1058
2089
  >
2090
+
2091
+ <h3>WebSocket Status</h3>
2092
+ <p>
2093
+ Optional <em>fromcubes</em> badge in the bottom-right corner showing
2094
+ connection state. Enable via the <strong>Show WebSocket status
2095
+ indicator</strong> checkbox (off by default).
2096
+ </p>
2097
+ </script>
2098
+
2099
+ <!-- ── Assets sidebar tab ──────────────────────────────────────── -->
2100
+ <script type="text/javascript">
2101
+ (function () {
2102
+ // Resolve httpNodeRoot for public URL prefix
2103
+ const nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
2104
+ const publicBase = nodeRoot + "/fromcubes/public/";
2105
+
2106
+ /**
2107
+ * Human-readable byte formatter for the assets sidebar.
2108
+ * @param {number} bytes
2109
+ * @returns {string} e.g. `"42 B"`, `"3.2 KB"`, `"1.4 MB"`.
2110
+ */
2111
+ function formatSize(bytes) {
2112
+ if (bytes < 1024) return bytes + " B";
2113
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
2114
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
2115
+ }
2116
+
2117
+ let allEntries = [];
2118
+ const collapsed = {};
2119
+
2120
+ const content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
2121
+ const fileList = $('<div style="padding:0;"></div>').appendTo(content);
2122
+
2123
+ // ── Toolbar ──
2124
+ const toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
2125
+ const fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
2126
+ $('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
2127
+ .on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
2128
+ .appendTo(toolbar);
2129
+ $('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-folder-open"></i> New folder</button>')
2130
+ .on("click", function (e) { e.preventDefault(); showNewFolderInput(""); })
2131
+ .appendTo(toolbar);
2132
+
2133
+ // ── Helpers ──
2134
+ /**
2135
+ * Linear scan over the most-recently-fetched asset list.
2136
+ * @param {string} p Relative path inside the assets root.
2137
+ * @returns {boolean}
2138
+ */
2139
+ function pathExists(p) {
2140
+ for (let i = 0; i < allEntries.length; i++) {
2141
+ if (allEntries[i].name === p) return true;
2142
+ }
2143
+ return false;
2144
+ }
2145
+
2146
+ /**
2147
+ * POST one or more files to the upload endpoint. Prompts before
2148
+ * overwriting existing paths. Each request is sent independently so
2149
+ * an upstream failure of one file does not block the rest; the sidebar
2150
+ * is refreshed once all responses have arrived.
2151
+ * @param {FileList|Array<File>} files
2152
+ * @param {string} targetDir Relative directory inside the assets root
2153
+ * (empty string for root).
2154
+ * @returns {void}
2155
+ */
2156
+ function uploadFiles(files, targetDir) {
2157
+ if (!files || files.length === 0) return;
2158
+ let toUpload = [];
2159
+ const duplicates = [];
2160
+ for (let i = 0; i < files.length; i++) {
2161
+ const uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
2162
+ if (pathExists(uploadPath)) {
2163
+ duplicates.push({ file: files[i], path: uploadPath });
2164
+ } else {
2165
+ toUpload.push({ file: files[i], path: uploadPath });
2166
+ }
2167
+ }
2168
+ if (duplicates.length > 0) {
2169
+ const names = duplicates.map(function (d) { return d.file.name; }).join(", ");
2170
+ if (confirm("These files already exist: " + names + "\nOverwrite?")) {
2171
+ toUpload = toUpload.concat(duplicates);
2172
+ }
2173
+ }
2174
+ if (toUpload.length === 0) return;
2175
+ let pending = toUpload.length;
2176
+ toUpload.forEach(function (item) {
2177
+ const reader = new FileReader();
2178
+ reader.onload = function () {
2179
+ $.ajax({
2180
+ type: "POST",
2181
+ url: "portal-react/assets/upload/" + item.path.split("/").map(encodeURIComponent).join("/"),
2182
+ data: new Uint8Array(reader.result),
2183
+ contentType: "application/octet-stream",
2184
+ processData: false,
2185
+ success: function () { if (--pending === 0) refreshList(); },
2186
+ error: function () {
2187
+ RED.notify("Upload failed: " + item.file.name, "error");
2188
+ if (--pending === 0) refreshList();
2189
+ },
2190
+ });
2191
+ };
2192
+ reader.readAsArrayBuffer(item.file);
2193
+ });
2194
+ }
2195
+
2196
+ fileInput.on("change", function () {
2197
+ uploadFiles(this.files, "");
2198
+ fileInput.val("");
2199
+ });
2200
+
2201
+ // External file drag & drop on content area (root upload)
2202
+ content.on("dragover", function (e) {
2203
+ if (e.originalEvent.dataTransfer.types.indexOf("Files") >= 0) {
2204
+ e.preventDefault();
2205
+ e.stopPropagation();
2206
+ content.css("background", "rgba(34,211,238,0.05)");
2207
+ }
2208
+ });
2209
+ content.on("dragleave", function (e) {
2210
+ e.preventDefault();
2211
+ content.css("background", "");
2212
+ });
2213
+ content.on("drop", function (e) {
2214
+ e.preventDefault();
2215
+ e.stopPropagation();
2216
+ content.css("background", "");
2217
+ const dt = e.originalEvent.dataTransfer;
2218
+ if (dt.files && dt.files.length > 0) {
2219
+ uploadFiles(dt.files, "");
2220
+ }
2221
+ });
2222
+
2223
+ /**
2224
+ * Move or rename an asset. Computes the new path from `fromPath`'s basename
2225
+ * + `toDir`. No-ops when source and destination are equal. Prompts before
2226
+ * overwriting an existing destination. Posts to the `move` admin endpoint
2227
+ * (auth- and CSRF-gated server-side); the sidebar refreshes on success.
2228
+ * @param {string} fromPath Current relative path of the item.
2229
+ * @param {string} toDir Destination directory (empty string for root).
2230
+ * @returns {void}
2231
+ */
2232
+ function moveItem(fromPath, toDir) {
2233
+ const filename = fromPath.split("/").pop();
2234
+ const newPath = toDir ? toDir + "/" + filename : filename;
2235
+ if (fromPath === newPath) return;
2236
+ if (pathExists(newPath)) {
2237
+ if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
2238
+ }
2239
+ $.ajax({
2240
+ type: "POST", url: "portal-react/assets/move",
2241
+ contentType: "application/json",
2242
+ data: JSON.stringify({ from: fromPath, to: newPath }),
2243
+ success: function () { refreshList(); },
2244
+ error: function (xhr) {
2245
+ const msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
2246
+ RED.notify("Move failed: " + msg, "error");
2247
+ },
2248
+ });
2249
+ }
2250
+
2251
+ // ── Inline new-folder input ──
2252
+ function showNewFolderInput(parentDir) {
2253
+ const existingInput = fileList.find(".fc-new-folder-row");
2254
+ if (existingInput.length) existingInput.remove();
2255
+
2256
+ const depth = parentDir ? parentDir.split("/").length : 0;
2257
+ const indent = 8 + depth * 18;
2258
+ const row = $('<div class="fc-new-folder-row" style="display:flex;align-items:center;gap:6px;padding:8px;border-bottom:1px solid var(--red-ui-secondary-border-color);background:var(--red-ui-tertiary-background);"></div>');
2259
+ row.css("padding-left", indent + "px");
2260
+ $('<i class="fa fa-folder" style="color:#fbbf24;font-size:13px;width:16px;text-align:center;"></i>').appendTo(row);
2261
+ const inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
2262
+ inp.appendTo(row);
2263
+ const okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Create"><i class="fa fa-check"></i></button>');
2264
+ const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
2265
+ function submit() {
2266
+ const name = inp.val().trim();
2267
+ if (!name) { row.remove(); return; }
2268
+ const p = parentDir ? parentDir + "/" + name : name;
2269
+ if (pathExists(p)) {
2270
+ RED.notify("Folder '" + name + "' already exists", "warning");
2271
+ return;
2272
+ }
2273
+ $.ajax({ type: "POST", url: "portal-react/assets/mkdir", contentType: "application/json", data: JSON.stringify({ path: p }),
2274
+ success: function () { row.remove(); refreshList(); },
2275
+ error: function () { RED.notify("Failed to create folder", "error"); },
2276
+ });
2277
+ }
2278
+ okBtn.on("click", function (e) { e.preventDefault(); submit(); });
2279
+ cancelBtn.on("click", function (e) { e.preventDefault(); row.remove(); });
2280
+ inp.on("keydown", function (e) {
2281
+ if (e.key === "Enter") submit();
2282
+ if (e.key === "Escape") row.remove();
2283
+ });
2284
+ okBtn.appendTo(row);
2285
+ cancelBtn.appendTo(row);
2286
+
2287
+ // Insert after the parent folder row, or at top
2288
+ if (parentDir) {
2289
+ const parentRow = fileList.find('[data-path="' + parentDir + '"]');
2290
+ if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
2291
+ } else {
2292
+ fileList.prepend(row);
2293
+ }
2294
+ inp.focus();
2295
+ }
2296
+
2297
+ // ── Context menu (native Node-RED style) ──
2298
+ let activeMenu = null;
2299
+ function closeMenu() {
2300
+ if (activeMenu) { activeMenu.remove(); activeMenu = null; }
2301
+ }
2302
+ $(document).on("click", closeMenu);
2303
+
2304
+ function showMenu(anchor, items) {
2305
+ closeMenu();
2306
+ const menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
2307
+ items.forEach(function (item) {
2308
+ if (item.divider) {
2309
+ menu.append('<li class="red-ui-menu-divider"></li>');
2310
+ return;
2311
+ }
2312
+ const li = $('<li></li>');
2313
+ const a = $('<a href="#"></a>');
2314
+ if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
2315
+ a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
2316
+ a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
2317
+ a.on("click", function (e) {
2318
+ e.preventDefault();
2319
+ e.stopPropagation();
2320
+ closeMenu();
2321
+ item.action();
2322
+ });
2323
+ li.append(a);
2324
+ menu.append(li);
2325
+ });
2326
+
2327
+ $("body").append(menu);
2328
+ activeMenu = menu;
2329
+
2330
+ // Position near the anchor
2331
+ const off = anchor.offset();
2332
+ let top = off.top + anchor.outerHeight() + 2;
2333
+ let left = off.left - menu.outerWidth() + anchor.outerWidth();
2334
+ if (left < 0) left = off.left;
2335
+ if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
2336
+ menu.css({ top: top, left: left });
2337
+ }
2338
+
2339
+ const adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
2340
+
2341
+ function showRenameInput(rowEl, fullPath, currentName) {
2342
+ const nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
2343
+ if (!nameSpan.length) return;
2344
+ const origText = nameSpan.text();
2345
+ const inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
2346
+ const okBtn = $('<button class="red-ui-button red-ui-button-small" style="color:var(--red-ui-text-color-success);" title="Save"><i class="fa fa-check"></i></button>');
2347
+ const cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
2348
+ const dotIdx = origText.lastIndexOf(".");
2349
+ const hasExt = dotIdx > 0; // has extension (not hidden file)
2350
+ const origExt = hasExt ? origText.slice(dotIdx) : "";
2351
+
2352
+ nameSpan.replaceWith(inp);
2353
+ inp.after(cancelBtn).after(okBtn);
2354
+ inp.focus();
2355
+ // Select only the name part before extension
2356
+ const el = inp[0];
2357
+ if (hasExt && el.setSelectionRange) {
2358
+ el.setSelectionRange(0, dotIdx);
2359
+ } else {
2360
+ inp.select();
2361
+ }
2362
+
2363
+ function restore() {
2364
+ okBtn.remove();
2365
+ cancelBtn.remove();
2366
+ inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
2367
+ }
2368
+ function submit() {
2369
+ const newName = inp.val().trim();
2370
+ if (!newName) {
2371
+ RED.notify("Name cannot be empty", "warning");
2372
+ return;
2373
+ }
2374
+ // Warn if extension changed or removed
2375
+ if (hasExt) {
2376
+ const newDot = newName.lastIndexOf(".");
2377
+ const newExt = newDot > 0 ? newName.slice(newDot) : "";
2378
+ if (newExt.toLowerCase() !== origExt.toLowerCase()) {
2379
+ if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
2380
+ }
2381
+ }
2382
+ if (newName === origText) { restore(); return; }
2383
+ const parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
2384
+ const newPath = parentDir ? parentDir + "/" + newName : newName;
2385
+ if (pathExists(newPath)) {
2386
+ RED.notify("'" + newName + "' already exists", "warning");
2387
+ return;
2388
+ }
2389
+ $.ajax({
2390
+ type: "POST", url: "portal-react/assets/move",
2391
+ contentType: "application/json",
2392
+ data: JSON.stringify({ from: fullPath, to: newPath }),
2393
+ success: function () { refreshList(); },
2394
+ error: function (xhr) {
2395
+ const msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
2396
+ RED.notify("Rename failed: " + msg, "error");
2397
+ restore();
2398
+ },
2399
+ });
2400
+ }
2401
+ okBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); submit(); });
2402
+ cancelBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); restore(); });
2403
+ inp.on("keydown", function (e) {
2404
+ if (e.key === "Enter") submit();
2405
+ if (e.key === "Escape") restore();
2406
+ });
2407
+ }
2408
+
2409
+ // ── Tree rendering ──
2410
+ function buildTree(entries) {
2411
+ // Build nested structure: { children: { name: { type, children, entry } } }
2412
+ const root = { children: {} };
2413
+ entries.forEach(function (e) {
2414
+ const parts = e.name.split("/");
2415
+ let node = root;
2416
+ for (let i = 0; i < parts.length; i++) {
2417
+ if (!node.children[parts[i]]) {
2418
+ node.children[parts[i]] = { children: {} };
2419
+ }
2420
+ node = node.children[parts[i]];
2421
+ }
2422
+ node.entry = e;
2423
+ });
2424
+ return root;
2425
+ }
2426
+
2427
+ const ROOT_KEY = "__root__";
2428
+
2429
+ function renderTree() {
2430
+ fileList.empty();
2431
+ const isOpen = !collapsed[ROOT_KEY];
2432
+
2433
+ // Root folder row — always visible
2434
+ const rootRow = $('<div style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
2435
+ const rootArrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
2436
+ rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
2437
+ rootRow.append(rootArrow);
2438
+ $('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(rootRow);
2439
+ $('<span style="flex:1;font-size:12px;cursor:pointer;opacity:0.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>').text(publicBase.replace(/\/$/, ""))
2440
+ .on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); })
2441
+ .appendTo(rootRow);
2442
+ fileList.append(rootRow);
2443
+
2444
+ if (!isOpen) return;
2445
+
2446
+ if (allEntries.length === 0) {
2447
+ fileList.append($('<div style="padding:16px;color:var(--red-ui-secondary-text-color);text-align:center;">No files uploaded.<br><span style="font-size:11px;">Drop files here or click Upload.</span></div>'));
2448
+ return;
2449
+ }
2450
+ const tree = buildTree(allEntries);
2451
+ renderNode(tree, "", 1);
2452
+ }
2453
+
2454
+ function renderNode(node, parentPath, depth) {
2455
+ // Collect and sort: dirs first, then files
2456
+ const names = Object.keys(node.children).sort(function (a, b) {
2457
+ const aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
2458
+ const bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
2459
+ if (aIsDir && !bIsDir) return -1;
2460
+ if (!aIsDir && bIsDir) return 1;
2461
+ return a.localeCompare(b);
2462
+ });
2463
+
2464
+ names.forEach(function (name) {
2465
+ const child = node.children[name];
2466
+ const e = child.entry;
2467
+ if (!e) return;
2468
+ const fullPath = e.name;
2469
+ const indent = 8 + depth * 18;
2470
+ const isDir = e.type === "dir";
2471
+ const isOpen = !collapsed[fullPath];
2472
+
2473
+ const row = $('<div data-path="' + fullPath + '" style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
2474
+ row.css("padding-left", indent + "px");
2475
+
2476
+ if (isDir) {
2477
+ // Expand/collapse arrow
2478
+ const arrow = $('<i class="fa ' + (isOpen ? 'fa-caret-down' : 'fa-caret-right') + '" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:10px;text-align:center;cursor:pointer;"></i>');
2479
+ arrow.on("click", function (e) {
2480
+ e.stopPropagation();
2481
+ collapsed[fullPath] = isOpen;
2482
+ renderTree();
2483
+ });
2484
+ row.append(arrow);
2485
+ $('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(row);
2486
+ $('<span style="flex:1;font-size:12px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name)
2487
+ .on("click", function () { collapsed[fullPath] = isOpen; renderTree(); })
2488
+ .appendTo(row);
2489
+
2490
+ // Context menu trigger
2491
+ const dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
2492
+ (function (fp, nm) {
2493
+ dirMenuBtn.on("click", function (ev) {
2494
+ ev.preventDefault();
2495
+ ev.stopPropagation();
2496
+ showMenu(dirMenuBtn, [
2497
+ { icon: "fa-pencil", label: "Rename", action: function () {
2498
+ showRenameInput(row, fp, nm);
2499
+ }},
2500
+ { icon: "fa-plus", label: "New subfolder", action: function () {
2501
+ if (collapsed[fp]) { collapsed[fp] = false; renderTree(); }
2502
+ setTimeout(function () { showNewFolderInput(fp); }, 50);
2503
+ }},
2504
+ { divider: true },
2505
+ { icon: "fa-trash", label: "Delete folder", danger: true, action: function () {
2506
+ if (!confirm("Delete folder '" + nm + "' and all contents?")) return;
2507
+ $.ajax({
2508
+ type: "DELETE",
2509
+ url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
2510
+ success: function () { refreshList(); },
2511
+ error: function () { RED.notify("Delete failed", "error"); },
2512
+ });
2513
+ }},
2514
+ ]);
2515
+ });
2516
+ })(fullPath, name);
2517
+ dirMenuBtn.appendTo(row);
2518
+
2519
+ // Drop target
2520
+ row.on("dragover", function (ev) {
2521
+ ev.preventDefault();
2522
+ ev.stopPropagation();
2523
+ row.css("background", "rgba(251,191,36,0.08)");
2524
+ });
2525
+ row.on("dragleave", function () { row.css("background", ""); });
2526
+ row.on("drop", function (ev) {
2527
+ ev.preventDefault();
2528
+ ev.stopPropagation();
2529
+ row.css("background", "");
2530
+ const srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
2531
+ if (srcPath) {
2532
+ moveItem(srcPath, fullPath);
2533
+ } else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
2534
+ uploadFiles(ev.originalEvent.dataTransfer.files, fullPath);
2535
+ }
2536
+ });
2537
+ fileList.append(row);
2538
+
2539
+ // Render children if open
2540
+ if (isOpen) {
2541
+ renderNode(child, fullPath, depth + 1);
2542
+ }
2543
+ } else {
2544
+ // File
2545
+ row.attr("draggable", "true").css("cursor", "grab");
2546
+ $('<span style="width:10px;"></span>').appendTo(row); // spacer for arrow alignment
2547
+ $('<i class="fa fa-file-o" style="color:var(--red-ui-secondary-text-color);font-size:11px;width:16px;text-align:center;"></i>').appendTo(row);
2548
+ $('<span style="flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name).appendTo(row);
2549
+ $('<span style="font-size:10px;color:var(--red-ui-tertiary-text-color);white-space:nowrap;"></span>').text(formatSize(e.size)).appendTo(row);
2550
+
2551
+ row.on("dragstart", function (ev) {
2552
+ ev.originalEvent.dataTransfer.setData("text/x-asset-path", fullPath);
2553
+ ev.originalEvent.dataTransfer.effectAllowed = "move";
2554
+ row.css("opacity", "0.4");
2555
+ });
2556
+ row.on("dragend", function () { row.css("opacity", "1"); });
2557
+
2558
+ const copyBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;" title="Copy public path"><i class="fa fa-clipboard"></i></button>');
2559
+ const fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
2560
+ (function (fp, nm) {
2561
+ copyBtn.on("click", function (ev) {
2562
+ ev.preventDefault();
2563
+ ev.stopPropagation();
2564
+ const url = publicBase + fp;
2565
+ navigator.clipboard.writeText(url).then(function () {
2566
+ RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
2567
+ });
2568
+ });
2569
+ fileMenuBtn.on("click", function (ev) {
2570
+ ev.preventDefault();
2571
+ ev.stopPropagation();
2572
+ showMenu(fileMenuBtn, [
2573
+ { icon: "fa-pencil", label: "Rename", action: function () {
2574
+ showRenameInput(row, fp, nm);
2575
+ }},
2576
+ { icon: "fa-download", label: "Download", action: function () {
2577
+ const a = document.createElement("a");
2578
+ a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
2579
+ a.download = nm;
2580
+ document.body.appendChild(a);
2581
+ a.click();
2582
+ document.body.removeChild(a);
2583
+ }},
2584
+ { divider: true },
2585
+ { icon: "fa-trash", label: "Delete", danger: true, action: function () {
2586
+ $.ajax({
2587
+ type: "DELETE",
2588
+ url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
2589
+ success: function () { refreshList(); },
2590
+ error: function () { RED.notify("Delete failed: " + nm, "error"); },
2591
+ });
2592
+ }},
2593
+ ]);
2594
+ });
2595
+ })(fullPath, name);
2596
+ copyBtn.appendTo(row);
2597
+ fileMenuBtn.appendTo(row);
2598
+ fileList.append(row);
2599
+ }
2600
+ });
2601
+ }
2602
+
2603
+ /**
2604
+ * Re-fetch the assets listing from the admin API and re-render the tree.
2605
+ * Called after every mutation (upload/move/delete/mkdir) and on the
2606
+ * Node-RED `sidebar:resize` event.
2607
+ * @returns {void}
2608
+ */
2609
+ function refreshList() {
2610
+ $.getJSON("portal-react/assets", function (entries) {
2611
+ allEntries = entries || [];
2612
+ renderTree();
2613
+ });
2614
+ }
2615
+
2616
+ RED.sidebar.addTab({
2617
+ id: "fromcubes-public",
2618
+ label: "Portal Assets",
2619
+ name: "fromcubes portal assets",
2620
+ iconClass: "fa fa-desktop",
2621
+ content: content[0],
2622
+ toolbar: toolbar[0],
2623
+ pinned: false,
2624
+ enableOnEdit: true,
2625
+ });
2626
+
2627
+ RED.actions.add("fromcubes:show-assets", function () {
2628
+ RED.sidebar.show("fromcubes-public");
2629
+ });
2630
+
2631
+ RED.events.on("sidebar:resize", function () { refreshList(); });
2632
+ setTimeout(refreshList, 1000);
2633
+ })();
1059
2634
  </script>