@aaqu/fromcubes-portal-react 0.1.0-alpha.2 → 0.1.0-alpha.20

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.
@@ -26,7 +26,10 @@
26
26
  // Idempotent — safe to call multiple times, only runs setup once.
27
27
  function ensureJsxSetup() {
28
28
  if (jsxSetupDone) {
29
- console.log(PREFIX, "ensureJsxSetup: already done, re-applying compiler+diag only");
29
+ console.log(
30
+ PREFIX,
31
+ "ensureJsxSetup: already done, re-applying compiler+diag only",
32
+ );
30
33
  // Always re-apply compiler/diag in case Node-RED reset them
31
34
  applyCompilerAndDiag();
32
35
  return;
@@ -36,8 +39,16 @@
36
39
  var jsDef = monaco.typescript.javascriptDefaults;
37
40
 
38
41
  // Log initial state
39
- console.log(PREFIX, "INITIAL compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
40
- console.log(PREFIX, "INITIAL diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
42
+ console.log(
43
+ PREFIX,
44
+ "INITIAL compilerOptions:",
45
+ JSON.stringify(jsDef.getCompilerOptions()),
46
+ );
47
+ console.log(
48
+ PREFIX,
49
+ "INITIAL diagnosticsOptions:",
50
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
51
+ );
41
52
 
42
53
  applyCompilerAndDiag();
43
54
 
@@ -45,7 +56,7 @@
45
56
  var libContent = [
46
57
  "declare var React: any;",
47
58
  "declare var ReactDOM: any;",
48
- "declare function useNodeRed(): { data: any; send: (payload: any, topic?: string) => void };",
59
+ "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
60
  ].join("\n");
50
61
  console.log(PREFIX, "addExtraLib globals.d.ts");
51
62
  jsDef.addExtraLib(libContent, "file:///globals.d.ts");
@@ -134,7 +145,17 @@
134
145
  });
135
146
  }
136
147
  }
137
- console.log(PREFIX, "TW completion: ctx=" + (isClassName ? "className" : "string") + " word='" + word + "', matched=" + suggestions.length + "/" + classes.length);
148
+ console.log(
149
+ PREFIX,
150
+ "TW completion: ctx=" +
151
+ (isClassName ? "className" : "string") +
152
+ " word='" +
153
+ word +
154
+ "', matched=" +
155
+ suggestions.length +
156
+ "/" +
157
+ classes.length,
158
+ );
138
159
  return { suggestions: suggestions };
139
160
  },
140
161
  });
@@ -142,19 +163,82 @@
142
163
  // JSX/HTML tag completion — type tag name, Tab → <tag>|</tag>
143
164
  console.log(PREFIX, "registering JSX tag completion provider");
144
165
  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",
166
+ "div",
167
+ "span",
168
+ "p",
169
+ "a",
170
+ "button",
171
+ "input",
172
+ "img",
173
+ "form",
174
+ "label",
175
+ "h1",
176
+ "h2",
177
+ "h3",
178
+ "h4",
179
+ "h5",
180
+ "h6",
181
+ "ul",
182
+ "ol",
183
+ "li",
184
+ "dl",
185
+ "dt",
186
+ "dd",
187
+ "table",
188
+ "thead",
189
+ "tbody",
190
+ "tfoot",
191
+ "tr",
192
+ "th",
193
+ "td",
194
+ "section",
195
+ "article",
196
+ "aside",
197
+ "header",
198
+ "footer",
199
+ "nav",
200
+ "main",
201
+ "strong",
202
+ "em",
203
+ "b",
204
+ "i",
205
+ "u",
206
+ "s",
207
+ "small",
208
+ "mark",
209
+ "code",
210
+ "pre",
211
+ "blockquote",
212
+ "br",
213
+ "hr",
214
+ "wbr",
215
+ "select",
216
+ "option",
217
+ "optgroup",
218
+ "textarea",
219
+ "fieldset",
220
+ "legend",
221
+ "details",
222
+ "summary",
223
+ "dialog",
224
+ "canvas",
225
+ "video",
226
+ "audio",
227
+ "source",
228
+ "picture",
229
+ "figure",
230
+ "figcaption",
231
+ "svg",
232
+ "path",
233
+ "circle",
234
+ "rect",
235
+ "line",
236
+ "g",
237
+ "defs",
238
+ "use",
239
+ "text",
156
240
  ];
157
- var VOID_TAGS = new Set(["br","hr","img","input","wbr","source"]);
241
+ var VOID_TAGS = new Set(["br", "hr", "img", "input", "wbr", "source"]);
158
242
 
159
243
  monaco.languages.registerCompletionItemProvider("javascript", {
160
244
  triggerCharacters: ["<"],
@@ -165,12 +249,15 @@
165
249
  // After "<" — suggest tags
166
250
  var afterBracket = before.match(/<([a-zA-Z]*)$/);
167
251
  // 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]*)$/);
252
+ var bareWord =
253
+ !afterBracket &&
254
+ before.match(/(?:^|[\s{(,])([a-zA-Z][a-zA-Z0-9]*)$/);
169
255
 
170
256
  if (!afterBracket && !bareWord) return { suggestions: [] };
171
257
 
172
258
  var typed = (afterBracket ? afterBracket[1] : bareWord[1]) || "";
173
- var replaceStart = position.column - typed.length - (afterBracket ? 1 : 0);
259
+ var replaceStart =
260
+ position.column - typed.length - (afterBracket ? 1 : 0);
174
261
 
175
262
  var range = {
176
263
  startLineNumber: position.lineNumber,
@@ -192,7 +279,8 @@
192
279
  label: tag,
193
280
  kind: monaco.languages.CompletionItemKind.Property,
194
281
  insertText: "<" + tag + " $0/>",
195
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
282
+ insertTextRules:
283
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
196
284
  range: range,
197
285
  detail: "<" + tag + " />",
198
286
  sortText: "0" + tag,
@@ -202,7 +290,8 @@
202
290
  label: tag,
203
291
  kind: monaco.languages.CompletionItemKind.Property,
204
292
  insertText: "<" + tag + ">$0</" + tag + ">",
205
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
293
+ insertTextRules:
294
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
206
295
  range: range,
207
296
  detail: "<" + tag + ">…</" + tag + ">",
208
297
  sortText: "0" + tag + "a",
@@ -211,7 +300,8 @@
211
300
  label: tag,
212
301
  kind: monaco.languages.CompletionItemKind.Property,
213
302
  insertText: "<" + tag + " $0/>",
214
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
303
+ insertTextRules:
304
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
215
305
  range: range,
216
306
  detail: "<" + tag + " />",
217
307
  sortText: "0" + tag + "b",
@@ -220,9 +310,15 @@
220
310
  }
221
311
 
222
312
  // 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] : "");
313
+ var upperMatch =
314
+ afterBracket && before.match(/<([A-Z][a-zA-Z0-9]*)$/);
315
+ var bareUpper =
316
+ !afterBracket && before.match(/(?:^|[\s{(,])([A-Z][a-zA-Z0-9]*)$/);
317
+ var compTyped = upperMatch
318
+ ? upperMatch[1]
319
+ : bareUpper
320
+ ? bareUpper[1]
321
+ : "";
226
322
 
227
323
  if (compTyped || (!typed && afterBracket)) {
228
324
  // Fetch component names from registry (cached on window)
@@ -231,13 +327,19 @@
231
327
  var name = reg[c];
232
328
  if (seen.has(name)) continue;
233
329
  if (compTyped && name.indexOf(compTyped) !== 0) continue;
234
- if (!compTyped && typed && name.toLowerCase().indexOf(typed) !== 0) continue;
330
+ if (
331
+ !compTyped &&
332
+ typed &&
333
+ name.toLowerCase().indexOf(typed) !== 0
334
+ )
335
+ continue;
235
336
  seen.add(name);
236
337
  suggestions.push({
237
338
  label: name,
238
339
  kind: monaco.languages.CompletionItemKind.Class,
239
340
  insertText: "<" + name + ">$0</" + name + ">",
240
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
341
+ insertTextRules:
342
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
241
343
  range: range,
242
344
  detail: "<" + name + ">…</" + name + ">",
243
345
  sortText: "0" + name + "a",
@@ -246,7 +348,8 @@
246
348
  label: name,
247
349
  kind: monaco.languages.CompletionItemKind.Class,
248
350
  insertText: "<" + name + " $0/>",
249
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
351
+ insertTextRules:
352
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
250
353
  range: range,
251
354
  detail: "<" + name + " />",
252
355
  sortText: "0" + name + "b",
@@ -258,7 +361,8 @@
258
361
  label: compTyped,
259
362
  kind: monaco.languages.CompletionItemKind.Class,
260
363
  insertText: "<" + compTyped + ">$0</" + compTyped + ">",
261
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
364
+ insertTextRules:
365
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
262
366
  range: range,
263
367
  detail: "<" + compTyped + ">…</" + compTyped + ">",
264
368
  sortText: "0" + compTyped + "a",
@@ -267,7 +371,8 @@
267
371
  label: compTyped,
268
372
  kind: monaco.languages.CompletionItemKind.Class,
269
373
  insertText: "<" + compTyped + " $0/>",
270
- insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
374
+ insertTextRules:
375
+ monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
271
376
  range: range,
272
377
  detail: "<" + compTyped + " />",
273
378
  sortText: "0" + compTyped + "b",
@@ -275,7 +380,13 @@
275
380
  }
276
381
  }
277
382
 
278
- console.log(PREFIX, "JSX tag completion: typed='" + typed + "', suggestions=" + suggestions.length);
383
+ console.log(
384
+ PREFIX,
385
+ "JSX tag completion: typed='" +
386
+ typed +
387
+ "', suggestions=" +
388
+ suggestions.length,
389
+ );
279
390
  return { suggestions: suggestions };
280
391
  },
281
392
  });
@@ -296,12 +407,14 @@
296
407
  var m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)>\/<\/\2>(.*)$/);
297
408
  var matchStr, tag, prefix;
298
409
  if (m) {
299
- prefix = m[1]; tag = m[2];
410
+ prefix = m[1];
411
+ tag = m[2];
300
412
  matchStr = "<" + tag + ">/</" + tag + ">";
301
413
  } else {
302
414
  m = line.match(/^(.*)<([a-zA-Z][a-zA-Z0-9]*)\/><\/\2>(.*)$/);
303
415
  if (!m) return;
304
- prefix = m[1]; tag = m[2];
416
+ prefix = m[1];
417
+ tag = m[2];
305
418
  matchStr = "<" + tag + "/></" + tag + ">";
306
419
  }
307
420
  var startCol = prefix.length + 1;
@@ -309,15 +422,17 @@
309
422
  var replacement = "<" + tag + " />";
310
423
  var cursorCol = startCol + replacement.length - 2;
311
424
  setTimeout(function () {
312
- editor.executeEdits("self-close-collapse", [{
313
- range: {
314
- startLineNumber: lineNum,
315
- endLineNumber: lineNum,
316
- startColumn: startCol,
317
- endColumn: endCol,
425
+ editor.executeEdits("self-close-collapse", [
426
+ {
427
+ range: {
428
+ startLineNumber: lineNum,
429
+ endLineNumber: lineNum,
430
+ startColumn: startCol,
431
+ endColumn: endCol,
432
+ },
433
+ text: replacement,
318
434
  },
319
- text: replacement,
320
- }]);
435
+ ]);
321
436
  editor.setPosition({ lineNumber: lineNum, column: cursorCol });
322
437
  }, 0);
323
438
  });
@@ -328,7 +443,11 @@
328
443
  function refreshComponentNames() {
329
444
  $.getJSON("portal-react/registry", function (reg) {
330
445
  window.__fcComponentNames = Object.keys(reg);
331
- console.log(PREFIX, "component names loaded:", window.__fcComponentNames);
446
+ console.log(
447
+ PREFIX,
448
+ "component names loaded:",
449
+ window.__fcComponentNames,
450
+ );
332
451
  }).fail(function () {
333
452
  window.__fcComponentNames = [];
334
453
  });
@@ -343,11 +462,19 @@
343
462
  console.group(PREFIX + " MARKERS on " + uri.toString());
344
463
  markers.forEach(function (m) {
345
464
  console.log(
346
- " code=" + (m.code || "?") +
347
- " severity=" + m.severity +
348
- " source=" + (m.source || "?") +
349
- " msg=" + m.message +
350
- " [L" + m.startLineNumber + ":" + m.startColumn + "]"
465
+ " code=" +
466
+ (m.code || "?") +
467
+ " severity=" +
468
+ m.severity +
469
+ " source=" +
470
+ (m.source || "?") +
471
+ " msg=" +
472
+ m.message +
473
+ " [L" +
474
+ m.startLineNumber +
475
+ ":" +
476
+ m.startColumn +
477
+ "]",
351
478
  );
352
479
  });
353
480
  console.groupEnd();
@@ -379,8 +506,16 @@
379
506
  console.log(PREFIX, "setDiagnosticsOptions:", JSON.stringify(diagOpts));
380
507
  jsDef.setDiagnosticsOptions(diagOpts);
381
508
 
382
- console.log(PREFIX, "readback compilerOptions:", JSON.stringify(jsDef.getCompilerOptions()));
383
- console.log(PREFIX, "readback diagnosticsOptions:", JSON.stringify(jsDef.getDiagnosticsOptions()));
509
+ console.log(
510
+ PREFIX,
511
+ "readback compilerOptions:",
512
+ JSON.stringify(jsDef.getCompilerOptions()),
513
+ );
514
+ console.log(
515
+ PREFIX,
516
+ "readback diagnosticsOptions:",
517
+ JSON.stringify(jsDef.getDiagnosticsOptions()),
518
+ );
384
519
  }
385
520
 
386
521
  // Exported for oneditprepare to call before model creation
@@ -391,7 +526,10 @@
391
526
  window.__fcLoadMonaco = function (cb) {
392
527
  console.log(PREFIX, "loadMonaco called, monaco exists:", !!window.monaco);
393
528
  if (window.monaco) {
394
- console.log(PREFIX, "Monaco already loaded (by Node-RED?), running setup inline");
529
+ console.log(
530
+ PREFIX,
531
+ "Monaco already loaded (by Node-RED?), running setup inline",
532
+ );
395
533
  ensureJsxSetup();
396
534
  cb();
397
535
  return;
@@ -429,26 +567,6 @@
429
567
  <label for="node-input-compName"><i class="fa fa-cube"></i> JSX Tag</label>
430
568
  <input type="text" id="node-input-compName" placeholder="MyComponent" />
431
569
  </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
570
  <div class="form-row" style="margin-bottom:4px;">
453
571
  <label><i class="fa fa-code"></i> JSX Code</label>
454
572
  </div>
@@ -491,13 +609,11 @@
491
609
  name: { value: "" },
492
610
  compName: { value: "StatusCard", required: true },
493
611
  compCode: { value: COMP_STARTER },
494
- compInputs: { value: "label,value,unit" },
495
- compOutputs: { value: "" },
496
612
  },
497
613
  inputs: 0,
498
614
  outputs: 0,
499
615
  icon: "font-awesome/fa-cube",
500
- paletteLabel: "component",
616
+ paletteLabel: "fromcubes component",
501
617
  label: function () {
502
618
  return this.name || this.compName || "component";
503
619
  },
@@ -510,22 +626,32 @@
510
626
  if (!window.__fcTwClasses) {
511
627
  console.log("[FC-Monaco] loading tw-classes...");
512
628
  $.getJSON("portal-react/tw-classes", function (classes) {
513
- console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
629
+ console.log(
630
+ "[FC-Monaco] tw-classes loaded, count=" + classes.length,
631
+ );
514
632
  window.__fcTwClasses = classes;
515
633
  }).fail(function (xhr) {
516
- console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
634
+ console.error(
635
+ "[FC-Monaco] tw-classes FAILED:",
636
+ xhr.status,
637
+ xhr.statusText,
638
+ );
517
639
  });
518
640
  }
519
641
 
520
642
  window.__fcLoadMonaco(function (failed) {
521
643
  if (failed) {
522
- console.error("[FC-Monaco] COMP: Monaco load failed, using fallback textarea");
644
+ console.error(
645
+ "[FC-Monaco] COMP: Monaco load failed, using fallback textarea",
646
+ );
523
647
  $("#fcc-monaco").hide();
524
648
  $("#fcc-fallback").show().val(code);
525
649
  return;
526
650
  }
527
651
 
528
- console.log("[FC-Monaco] COMP: applying JSX defaults before model creation");
652
+ console.log(
653
+ "[FC-Monaco] COMP: applying JSX defaults before model creation",
654
+ );
529
655
  window.__fcApplyJsxDefaults();
530
656
 
531
657
  var compUri = monaco.Uri.parse("file:///fc-comp-" + node.id + ".jsx");
@@ -535,8 +661,15 @@
535
661
  console.log("[FC-Monaco] COMP: disposing existing model");
536
662
  existingModel.dispose();
537
663
  }
538
- var compModel = monaco.editor.createModel(code, "javascript", compUri);
539
- console.log("[FC-Monaco] COMP: model created, language=" + compModel.getLanguageId());
664
+ var compModel = monaco.editor.createModel(
665
+ code,
666
+ "javascript",
667
+ compUri,
668
+ );
669
+ console.log(
670
+ "[FC-Monaco] COMP: model created, language=" +
671
+ compModel.getLanguageId(),
672
+ );
540
673
 
541
674
  compEditorInstance = monaco.editor.create(
542
675
  document.getElementById("fcc-monaco"),
@@ -547,13 +680,27 @@
547
680
  // Log markers after a short delay (diagnostics are async)
548
681
  setTimeout(function () {
549
682
  var markers = monaco.editor.getModelMarkers({ resource: compUri });
550
- console.log("[FC-Monaco] COMP: markers after 500ms, count=" + markers.length);
683
+ console.log(
684
+ "[FC-Monaco] COMP: markers after 500ms, count=" + markers.length,
685
+ );
551
686
  markers.forEach(function (m) {
552
- console.log("[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message);
687
+ console.log(
688
+ "[FC-Monaco] COMP marker: code=" + m.code + " msg=" + m.message,
689
+ );
553
690
  });
554
691
  // 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()));
692
+ console.log(
693
+ "[FC-Monaco] COMP: current compilerOptions:",
694
+ JSON.stringify(
695
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
696
+ ),
697
+ );
698
+ console.log(
699
+ "[FC-Monaco] COMP: current diagnosticsOptions:",
700
+ JSON.stringify(
701
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
702
+ ),
703
+ );
557
704
  }, 500);
558
705
  });
559
706
  },
@@ -631,6 +778,34 @@
631
778
  <ul id="fc-tabs" style="min-width:600px;margin-bottom:0;"></ul>
632
779
  </div>
633
780
  <div id="fc-tabs-content">
781
+ <!-- ── Tab: JSX ── -->
782
+ <div id="fc-tab-jsx" class="fc-tab-pane">
783
+ <div class="form-row node-text-editor-row">
784
+ <div
785
+ id="fc-editor-wrap"
786
+ style="width:100%;height:420px;border:1px solid var(--red-ui-form-input-border-color,#555);border-radius:4px;overflow:hidden;position:relative;"
787
+ >
788
+ <div id="fc-monaco" style="width:100%;height:100%;"></div>
789
+ <textarea
790
+ id="fc-fallback"
791
+ 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;"
792
+ ></textarea>
793
+ </div>
794
+ </div>
795
+ <div class="form-row" style="display:flex;gap:8px;margin-top:4px;flex-wrap:wrap;">
796
+ <button type="button" class="red-ui-button" id="fc-btn-starter">
797
+ <i class="fa fa-magic"></i> Default
798
+ </button>
799
+ <button type="button" class="red-ui-button" id="fc-btn-components">
800
+ <i class="fa fa-cube"></i> Components
801
+ </button>
802
+ <span style="flex:1"></span>
803
+ <button type="button" class="red-ui-button" id="fc-btn-preview">
804
+ <i class="fa fa-eye"></i> Preview
805
+ </button>
806
+ </div>
807
+ </div>
808
+
634
809
  <!-- ── Tab: Properties ── -->
635
810
  <div id="fc-tab-props" class="fc-tab-pane" style="display:none;">
636
811
  <div class="form-row">
@@ -638,10 +813,22 @@
638
813
  <input type="text" id="node-input-name" placeholder="My Portal" />
639
814
  </div>
640
815
  <div class="form-row">
641
- <label for="node-input-endpoint"
642
- ><i class="fa fa-globe"></i> Endpoint</label
816
+ <label for="node-input-subPath"
817
+ ><i class="fa fa-globe"></i> Sub-path</label
643
818
  >
644
- <input type="text" id="node-input-endpoint" placeholder="/portal" />
819
+ <div style="display:inline-flex;align-items:stretch;width:70%;">
820
+ <span
821
+ 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;"
822
+ >/fromcubes/</span
823
+ >
824
+ <input
825
+ type="text"
826
+ id="node-input-subPath"
827
+ placeholder="sensors"
828
+ style="flex:1;border-radius:0 4px 4px 0;"
829
+ />
830
+ </div>
831
+ <input type="hidden" id="node-input-endpoint" />
645
832
  <div
646
833
  style="font-size:11px;opacity:.5;margin-top:2px;margin-left:105px;"
647
834
  id="fc-url-hint"
@@ -653,41 +840,41 @@
653
840
  >
654
841
  <input type="text" id="node-input-pageTitle" placeholder="Portal" />
655
842
  </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">
843
+ <div class="form-row node-input-libs-container-row">
844
+ <label style="width:auto;"><i class="fa fa-archive"></i> Modules</label>
845
+ <ol id="node-input-libs-container"></ol>
670
846
  <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;"
847
+ style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;"
673
848
  >
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>
849
+ npm packages to bundle. Node-RED auto-installs them at deploy time.
679
850
  </div>
680
851
  </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>
852
+ <div class="form-row" style="margin-top:12px;">
853
+ <label style="width:auto;">
854
+ <input
855
+ type="checkbox"
856
+ id="node-input-showWsStatus"
857
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
858
+ />
859
+ Show WebSocket status indicator
860
+ </label>
861
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
862
+ Displays a small <em>fromcubes</em> badge in the bottom-right corner
863
+ showing connection state.
864
+ </div>
865
+ </div>
866
+ <div class="form-row" style="margin-top:12px;">
867
+ <label style="width:auto;">
868
+ <input
869
+ type="checkbox"
870
+ id="node-input-portalAuth"
871
+ style="width:auto;margin:0 8px 0 0;vertical-align:middle;"
872
+ />
873
+ Enable Portal Auth headers
874
+ </label>
875
+ <div style="font-size:11px;opacity:.5;margin-top:4px;margin-left:4px;">
876
+ Read <code>X-Portal-*</code> headers from reverse proxy.
877
+ </div>
691
878
  </div>
692
879
  </div>
693
880
 
@@ -723,7 +910,7 @@
723
910
  var headEditorInstance = null;
724
911
 
725
912
  var STARTER = [
726
- "// useNodeRed() \u2192 { data, send }",
913
+ "// useNodeRed() \u2192 { data, send, portalClient }",
727
914
  "// data = last msg.payload from input wire",
728
915
  "// send(payload, topic?) = push msg to output wire",
729
916
  "// Components from fc-portal-component nodes are available by name.",
@@ -734,7 +921,7 @@
734
921
  "",
735
922
  " return (",
736
923
  ' <div className="min-h-screen bg-zinc-950 p-8">',
737
- ' <h1 className="text-2xl font-light text-cyan-400 mb-6">Portal</h1>',
924
+ ' <h1 className="text-2xl font-light text-cyan-400 mb-6">fromcubes</h1>',
738
925
  ' <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">',
739
926
  ' <StatusCard label="Value" value={d.value ?? "—"} unit="" />',
740
927
  " </div>",
@@ -754,17 +941,40 @@
754
941
  color: "#61dafb",
755
942
  defaults: {
756
943
  name: { value: "" },
757
- endpoint: { value: "/portal", required: true },
758
- pageTitle: { value: "Portal" },
944
+ subPath: {
945
+ value: "",
946
+ required: true,
947
+ validate: function (v) {
948
+ if (typeof v !== "string") return false;
949
+ var t = v.trim();
950
+ if (t.length === 0) return false;
951
+ if (/\s/.test(t)) return false;
952
+ if (t.charAt(0) === "/" || t.charAt(t.length - 1) === "/") return false;
953
+ var segs = t.split("/");
954
+ for (var i = 0; i < segs.length; i++) {
955
+ var s = segs[i];
956
+ if (!s || s === "." || s === "..") return false;
957
+ var lower = s.toLowerCase();
958
+ if (lower === "public" || lower === "_ws") return false;
959
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(s)) return false;
960
+ }
961
+ return true;
962
+ },
963
+ },
964
+ endpoint: { value: "" },
965
+ pageTitle: { value: "fromcubes" },
759
966
  componentCode: { value: STARTER },
760
967
  customHead: { value: "" },
968
+ portalAuth: { value: false },
969
+ showWsStatus: { value: false },
970
+ libs: { value: [] },
761
971
  },
762
972
  inputs: 1,
763
973
  outputs: 1,
764
974
  icon: "font-awesome/fa-desktop",
765
- paletteLabel: "portal react",
975
+ paletteLabel: "fromcubes portal",
766
976
  label: function () {
767
- return this.name || this.endpoint || "portal react";
977
+ return this.name || ("/fromcubes/" + (this.subPath || "?"));
768
978
  },
769
979
 
770
980
  oneditprepare: function () {
@@ -776,10 +986,16 @@
776
986
  if (!window.__fcTwClasses) {
777
987
  console.log("[FC-Monaco] loading tw-classes...");
778
988
  $.getJSON("portal-react/tw-classes", function (classes) {
779
- console.log("[FC-Monaco] tw-classes loaded, count=" + classes.length);
989
+ console.log(
990
+ "[FC-Monaco] tw-classes loaded, count=" + classes.length,
991
+ );
780
992
  window.__fcTwClasses = classes;
781
993
  }).fail(function (xhr) {
782
- console.error("[FC-Monaco] tw-classes FAILED:", xhr.status, xhr.statusText);
994
+ console.error(
995
+ "[FC-Monaco] tw-classes FAILED:",
996
+ xhr.status,
997
+ xhr.statusText,
998
+ );
783
999
  });
784
1000
  }
785
1001
 
@@ -800,18 +1016,73 @@
800
1016
  fcTabs.addTab({ id: "fc-tab-head", label: "Head HTML" });
801
1017
  fcTabs.activateTab("fc-tab-jsx");
802
1018
 
1019
+ // Legacy endpoint detection + convenience pre-fill
1020
+ if (node.endpoint && typeof node.endpoint === "string") {
1021
+ var legacy = node.endpoint;
1022
+ var prefix = "/fromcubes/";
1023
+ if (legacy.indexOf(prefix) === 0) {
1024
+ if (!node.subPath) {
1025
+ $("#node-input-subPath").val(legacy.slice(prefix.length));
1026
+ }
1027
+ } else if (legacy !== "" && legacy !== "/fromcubes") {
1028
+ $("#fc-url-hint")
1029
+ .css("color", "#c00")
1030
+ .text(
1031
+ "Legacy endpoint detected: '" +
1032
+ legacy +
1033
+ "'. Set a Sub-path and redeploy.",
1034
+ );
1035
+ }
1036
+ }
1037
+
803
1038
  // URL hint
804
1039
  function updateHint() {
805
- var ep = $("#node-input-endpoint").val() || "/portal";
806
- $("#fc-url-hint").text("Page served at: http://<host>:1880" + ep);
1040
+ var sp = ($("#node-input-subPath").val() || "").trim();
1041
+ var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1042
+ if (sp) {
1043
+ $("#fc-url-hint")
1044
+ .css("color", "")
1045
+ .text(
1046
+ "Page served at: http://<host>:1880" +
1047
+ root +
1048
+ "/fromcubes/" +
1049
+ sp,
1050
+ );
1051
+ } else if (!node.endpoint || node.endpoint.indexOf("/fromcubes/") === 0) {
1052
+ $("#fc-url-hint")
1053
+ .css("color", "")
1054
+ .text("Sub-path required (will be served under /fromcubes/<sub-path>)");
1055
+ }
807
1056
  }
808
- $("#node-input-endpoint").on("input", updateHint);
1057
+ $("#node-input-subPath").on("input", updateHint);
809
1058
  updateHint();
810
1059
 
1060
+ // Modules editableList (like function node's libs)
1061
+ var libsList = node.libs || [];
1062
+ $("#node-input-libs-container").css("min-height","68px").editableList({
1063
+ addItem: function(container, i, opt) {
1064
+ var lib = opt || {};
1065
+ var row = $('<div/>',{style:"display:flex;gap:8px;align-items:center;"}).appendTo(container);
1066
+ var modInput = $('<input/>',{type:"text",placeholder:"e.g. chart.js/auto@^4.4.0",style:"flex:1;"}).appendTo(row);
1067
+ var varInput = $('<input/>',{type:"text",placeholder:"Import as (e.g. Chart)",style:"width:140px;"}).appendTo(row);
1068
+ modInput.val(lib.module || "");
1069
+ varInput.val(lib.var || "");
1070
+ container.data("mod", modInput);
1071
+ container.data("var", varInput);
1072
+ },
1073
+ removable: true,
1074
+ sortable: true
1075
+ });
1076
+ libsList.forEach(function(lib) {
1077
+ $("#node-input-libs-container").editableList("addItem", lib);
1078
+ });
1079
+
811
1080
  // Monaco
812
1081
  window.__fcLoadMonaco(function (failed) {
813
1082
  if (failed) {
814
- console.error("[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea");
1083
+ console.error(
1084
+ "[FC-Monaco] PORTAL: Monaco load failed, using fallback textarea",
1085
+ );
815
1086
  $("#fc-monaco").hide();
816
1087
  $("#fc-fallback").show().val(code);
817
1088
  $("#fc-head-monaco").hide();
@@ -819,11 +1090,15 @@
819
1090
  return;
820
1091
  }
821
1092
 
822
- console.log("[FC-Monaco] PORTAL: applying JSX defaults before model creation");
1093
+ console.log(
1094
+ "[FC-Monaco] PORTAL: applying JSX defaults before model creation",
1095
+ );
823
1096
  window.__fcApplyJsxDefaults();
824
1097
  var opts = window.__fcEditorOpts;
825
1098
 
826
- var jsxUri = monaco.Uri.parse("file:///fc-portal-" + node.id + ".jsx");
1099
+ var jsxUri = monaco.Uri.parse(
1100
+ "file:///fc-portal-" + node.id + ".jsx",
1101
+ );
827
1102
  console.log("[FC-Monaco] PORTAL: JSX model URI=" + jsxUri.toString());
828
1103
  var existingJsx = monaco.editor.getModel(jsxUri);
829
1104
  if (existingJsx) {
@@ -831,15 +1106,21 @@
831
1106
  existingJsx.dispose();
832
1107
  }
833
1108
  var jsxModel = monaco.editor.createModel(code, "javascript", jsxUri);
834
- console.log("[FC-Monaco] PORTAL: JSX model created, language=" + jsxModel.getLanguageId());
1109
+ console.log(
1110
+ "[FC-Monaco] PORTAL: JSX model created, language=" +
1111
+ jsxModel.getLanguageId(),
1112
+ );
835
1113
  editorInstance = monaco.editor.create(
836
1114
  document.getElementById("fc-monaco"),
837
1115
  Object.assign({ model: jsxModel }, opts),
838
1116
  );
839
1117
  console.log("[FC-Monaco] PORTAL: JSX editor created");
840
- if (window.__fcAttachSelfClose) window.__fcAttachSelfClose(editorInstance);
1118
+ if (window.__fcAttachSelfClose)
1119
+ window.__fcAttachSelfClose(editorInstance);
841
1120
 
842
- var headUri = monaco.Uri.parse("file:///fc-head-" + node.id + ".html");
1121
+ var headUri = monaco.Uri.parse(
1122
+ "file:///fc-head-" + node.id + ".html",
1123
+ );
843
1124
  var existingHead = monaco.editor.getModel(headUri);
844
1125
  if (existingHead) existingHead.dispose();
845
1126
  var headModel = monaco.editor.createModel(headCode, "html", headUri);
@@ -852,77 +1133,167 @@
852
1133
  // Log markers after a short delay (diagnostics are async)
853
1134
  setTimeout(function () {
854
1135
  var markers = monaco.editor.getModelMarkers({ resource: jsxUri });
855
- console.log("[FC-Monaco] PORTAL: markers after 500ms, count=" + markers.length);
1136
+ console.log(
1137
+ "[FC-Monaco] PORTAL: markers after 500ms, count=" +
1138
+ markers.length,
1139
+ );
856
1140
  markers.forEach(function (m) {
857
- console.log("[FC-Monaco] PORTAL marker: code=" + m.code + " severity=" + m.severity + " msg=" + m.message);
1141
+ console.log(
1142
+ "[FC-Monaco] PORTAL marker: code=" +
1143
+ m.code +
1144
+ " severity=" +
1145
+ m.severity +
1146
+ " msg=" +
1147
+ m.message,
1148
+ );
858
1149
  });
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()));
1150
+ console.log(
1151
+ "[FC-Monaco] PORTAL: current compilerOptions:",
1152
+ JSON.stringify(
1153
+ monaco.typescript.javascriptDefaults.getCompilerOptions(),
1154
+ ),
1155
+ );
1156
+ console.log(
1157
+ "[FC-Monaco] PORTAL: current diagnosticsOptions:",
1158
+ JSON.stringify(
1159
+ monaco.typescript.javascriptDefaults.getDiagnosticsOptions(),
1160
+ ),
1161
+ );
861
1162
  }, 500);
862
1163
  });
863
1164
 
864
1165
  // Buttons
865
1166
  $("#fc-btn-starter").on("click", function () {
866
- if (editorInstance) editorInstance.setValue(STARTER);
867
- else $("#fc-fallback").val(STARTER);
1167
+ $("<div>Replace current JSX with default starter code?</div>").dialog({
1168
+ title: "Load Default",
1169
+ modal: true,
1170
+ width: 360,
1171
+ buttons: [
1172
+ { text: "Cancel", click: function () { $(this).dialog("close"); } },
1173
+ { text: "Replace", class: "primary", click: function () {
1174
+ if (editorInstance) editorInstance.setValue(STARTER);
1175
+ else $("#fc-fallback").val(STARTER);
1176
+ $(this).dialog("close");
1177
+ }},
1178
+ ],
1179
+ close: function () { $(this).remove(); },
1180
+ });
868
1181
  });
869
1182
 
870
1183
  $("#fc-btn-preview").on("click", function () {
871
- var ep = $("#node-input-endpoint").val();
872
- if (ep) window.open(ep, "_blank");
1184
+ var sp = ($("#node-input-subPath").val() || "").trim();
1185
+ var root = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1186
+ if (sp) window.open(root + "/fromcubes/" + sp, "_blank");
873
1187
  });
874
1188
 
875
1189
  $("#fc-btn-components").on("click", function () {
876
1190
  $.getJSON("portal-react/registry", function (reg) {
877
- var names = Object.keys(reg);
1191
+ var names = Object.keys(reg).sort();
878
1192
  if (!names.length) {
879
1193
  RED.notify("No component nodes on canvas.", "warning");
880
1194
  return;
881
1195
  }
882
- var html = "<p>Click to insert:</p>";
1196
+
1197
+ function extractProps(code) {
1198
+ if (!code) return [];
1199
+ var m = code.match(/function\s+\w+\s*\(\s*\{([^}]*)\}/);
1200
+ if (!m) return [];
1201
+ return m[1].split(",").map(function (s) { return s.trim().split(/\s*=\s*/)[0]; }).filter(Boolean);
1202
+ }
1203
+
1204
+ var html =
1205
+ '<div style="display:flex;flex-direction:column;height:100%;overflow:hidden;">' +
1206
+ '<input type="text" id="fc-comp-search" placeholder="Search..." ' +
1207
+ 'style="width:100%;box-sizing:border-box;margin-bottom:6px;padding:5px 8px;border:1px solid rgba(128,128,128,.4);border-radius:3px;' +
1208
+ 'background:var(--red-ui-form-input-background-color,#fff);color:var(--red-ui-form-text-color,#333);font-size:12px;flex-shrink:0;" />' +
1209
+ '<div id="fc-comp-list" style="flex:1;overflow-y:auto;">';
1210
+
883
1211
  names.forEach(function (n) {
884
1212
  var c = reg[n];
1213
+ var props = extractProps(c.code);
1214
+ var detailParts = [];
1215
+ if (props.length) {
1216
+ detailParts.push('<div style="margin:4px 0 0 0;font-size:11px;opacity:.6;">' +
1217
+ props.map(function (p) {
1218
+ return '<code style="background:rgba(128,128,128,.15);padding:0 4px;border-radius:2px;margin-right:3px;">' + p + '</code>';
1219
+ }).join("") + '</div>');
1220
+ }
1221
+ var hasDetail = detailParts.length > 0;
1222
+
885
1223
  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>";
1224
+ '<div class="fc-comp-item" data-name="' + n + '" data-search="' + n.toLowerCase() + '" ' +
1225
+ 'style="border-bottom:1px solid rgba(128,128,128,.1);">' +
1226
+ '<div style="display:flex;align-items:center;padding:4px 6px;cursor:pointer;" class="fc-comp-row">' +
1227
+ (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>') +
1228
+ '<span class="fc-comp-name" data-name="' + n + '" style="font-weight:600;font-size:12px;flex:1;">' + n + '</span>' +
1229
+ '</div>' +
1230
+ (hasDetail ? '<div class="fc-comp-detail" style="display:none;padding:0 6px 4px 20px;">' + detailParts.join("") + '</div>' : '') +
1231
+ '</div>';
897
1232
  });
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
- },
1233
+ html += '</div></div>';
1234
+
1235
+ var $dlg = $("<div></div>").html(html).dialog({
1236
+ title: "Components",
1237
+ modal: true,
1238
+ width: 380,
1239
+ buttons: [
1240
+ { text: "Close", click: function () { $(this).dialog("close"); } },
1241
+ ],
1242
+ close: function () { $(this).remove(); },
1243
+ });
1244
+
1245
+ // Search
1246
+ $dlg.find("#fc-comp-search").on("input", function () {
1247
+ var q = $(this).val().toLowerCase();
1248
+ $dlg.find(".fc-comp-item").each(function () {
1249
+ $(this).toggle($(this).data("search").indexOf(q) !== -1);
915
1250
  });
916
- $(document).on("click", ".fc-lib-ins", function () {
917
- var tag = "<" + $(this).data("name") + " />";
1251
+ }).focus();
1252
+
1253
+ // Hover
1254
+ $dlg.on("mouseenter", ".fc-comp-row", function () {
1255
+ $(this).css("background", "rgba(128,128,128,.1)");
1256
+ }).on("mouseleave", ".fc-comp-row", function () {
1257
+ $(this).css("background", "");
1258
+ });
1259
+
1260
+ // Arrow toggle detail
1261
+ $dlg.on("click", ".fc-comp-arrow", function (e) {
1262
+ e.stopPropagation();
1263
+ var $item = $(this).closest(".fc-comp-item");
1264
+ var $detail = $item.find(".fc-comp-detail");
1265
+ var open = $detail.is(":visible");
1266
+ $detail.slideToggle(100);
1267
+ $(this).css("transform", open ? "" : "rotate(90deg)");
1268
+ });
1269
+
1270
+ // Click name: delete selection, insert <Tag></Tag> in one line, cursor between tags
1271
+ $dlg.on("click", ".fc-comp-name", function () {
1272
+ var name = $(this).data("name");
1273
+ var openTag = "<" + name + ">";
1274
+ var closeTag = "</" + name + ">";
1275
+ var text = openTag + closeTag;
1276
+ $dlg.dialog("close");
918
1277
  if (editorInstance) {
919
- editorInstance.trigger("keyboard", "type", { text: tag });
920
- editorInstance.focus();
1278
+ var sel = editorInstance.getSelection();
1279
+ var startLine = sel.startLineNumber;
1280
+ var startCol = sel.startColumn;
1281
+ editorInstance.executeEdits("fc-components", [{
1282
+ range: sel,
1283
+ text: text,
1284
+ }]);
1285
+ setTimeout(function () {
1286
+ editorInstance.setPosition({ lineNumber: startLine, column: startCol + openTag.length });
1287
+ editorInstance.focus();
1288
+ }, 50);
921
1289
  } else {
922
1290
  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);
1291
+ var s = ta.selectionStart, e = ta.selectionEnd, v = ta.value;
1292
+ ta.value = v.slice(0, s) + text + v.slice(e);
1293
+ setTimeout(function () {
1294
+ ta.selectionStart = ta.selectionEnd = s + openTag.length;
1295
+ ta.focus();
1296
+ }, 50);
926
1297
  }
927
1298
  });
928
1299
  });
@@ -931,6 +1302,24 @@
931
1302
 
932
1303
  oneditsave: function () {
933
1304
  console.log("[FC-Monaco] PORTAL oneditsave");
1305
+
1306
+ // Clear legacy endpoint field + normalize subPath
1307
+ $("#node-input-endpoint").val("");
1308
+ this.endpoint = "";
1309
+ this.subPath = ($("#node-input-subPath").val() || "").trim();
1310
+
1311
+ // Collect libs from editableList
1312
+ var libs = [];
1313
+ var items = $("#node-input-libs-container").editableList("items");
1314
+ items.each(function() {
1315
+ var mod = $(this).data("mod").val().trim();
1316
+ var v = $(this).data("var").val().trim();
1317
+ if (mod) {
1318
+ libs.push({ module: mod, var: v });
1319
+ }
1320
+ });
1321
+ this.libs = libs;
1322
+
934
1323
  var code = editorInstance
935
1324
  ? editorInstance.getValue()
936
1325
  : $("#fc-fallback").val();
@@ -997,37 +1386,59 @@
997
1386
  Help
998
1387
  ============================================================ -->
999
1388
  <script type="text/html" data-help-name="portal-react">
1389
+ <h3>Quick Reference</h3>
1390
+ <ul>
1391
+ <li><code>useNodeRed()</code> &rarr; <code>{ data, send, user, portalClient }</code></li>
1392
+ <li>Components from <code>fc-portal-component</code> nodes are auto-imported</li>
1393
+ <li>Must export <code>&lt;App /&gt;</code></li>
1394
+ <li>JSX is transpiled server-side at deploy</li>
1395
+ </ul>
1396
+
1000
1397
  <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).
1398
+ Renders a React 19 application on a configurable HTTP endpoint with live
1399
+ WebSocket data binding. JSX is transpiled <strong>server-side at deploy
1400
+ time</strong> using esbuild — browsers receive pre-compiled JS with zero
1401
+ runtime compilation. Tailwind CSS 4 utility classes are generated
1402
+ server-side and served as static CSS.
1009
1403
  </p>
1010
1404
 
1011
1405
  <h3>Inputs</h3>
1012
1406
  <p>
1013
- <code>msg.payload</code> is pushed to all connected clients via WebSocket.
1407
+ <code>msg.payload</code> is pushed via WebSocket. Set <code>msg._client</code>
1408
+ to target specific clients, or omit it to broadcast to all.
1014
1409
  </p>
1015
1410
  <pre>
1016
- const { data, send } = useNodeRed();
1017
- // data = last msg.payload</pre
1411
+ const { data, send, user, portalClient } = useNodeRed();
1412
+ // data = last msg.payload (reactive)
1413
+ // send(payload, topic?) — emit msg on output wire
1414
+ // user = portal auth data or null
1415
+ // portalClient = unique session/tab ID (assigned by server)</pre
1018
1416
  >
1019
1417
 
1020
1418
  <h3>Outputs</h3>
1021
1419
  <p>
1022
1420
  <code>send(payload, topic?)</code> emits a <code>msg</code> on the node's
1023
- output wire.
1421
+ output wire via WebSocket.
1422
+ </p>
1423
+
1424
+ <h3>npm Packages</h3>
1425
+ <p>
1426
+ Add npm packages in the <strong>Modules</strong> list (e.g.
1427
+ <code>chart.js/auto@^4.4.0</code>, <code>d3</code>, <code>three</code>).
1428
+ Node-RED auto-installs them at deploy time. All packages are bundled with
1429
+ React into a single vendor IIFE via esbuild, cached by hash of installed
1430
+ versions. Use them in JSX via standard imports.
1024
1431
  </p>
1025
1432
 
1026
1433
  <h3>Deploy behavior</h3>
1027
1434
  <ul>
1028
1435
  <li>
1029
- Each deploy re-transpiles JSX (cached by content hash unchanged code is
1030
- instant)
1436
+ Each deploy re-transpiles JSX via esbuild and rebuilds Tailwind CSS
1437
+ (both cached by content hash — unchanged code is instant)
1438
+ </li>
1439
+ <li>
1440
+ Vendor bundle (React + npm packages) rebuilt only when package versions
1441
+ change
1031
1442
  </li>
1032
1443
  <li>
1033
1444
  Active WebSocket clients receive close code 1001 and auto-reconnect
@@ -1045,15 +1456,548 @@ const { data, send } = useNodeRed();
1045
1456
  <p>
1046
1457
  Components defined in <strong>fc-portal-component</strong> nodes on the
1047
1458
  canvas are auto-injected into every portal-react page. Use them as JSX tags
1048
- by their component name.
1459
+ by their component name — no imports needed.
1049
1460
  </p>
1050
1461
 
1462
+ <h3>Portal Auth</h3>
1463
+ <p>
1464
+ When enabled in the Auth tab, reads <code>X-Portal-*</code> headers set by
1465
+ a reverse proxy (e.g. Nginx) and exposes user data:
1466
+ </p>
1467
+ <ul>
1468
+ <li><code>useNodeRed()</code> returns <code>{ data, send, user, portalClient }</code></li>
1469
+ <li>
1470
+ All WebSocket messages include <code>msg._client</code> with
1471
+ <code>{ portalClient, ...userFields }</code>
1472
+ </li>
1473
+ <li>
1474
+ To target a specific tab: keep <code>msg._client</code> (or set
1475
+ <code>msg._client = { portalClient: "..." }</code>)
1476
+ </li>
1477
+ <li>
1478
+ To target all tabs of a user: set
1479
+ <code>msg._client = { userId: "..." }</code> (no portalClient)
1480
+ </li>
1481
+ <li>
1482
+ To broadcast to all: remove <code>msg._client</code>
1483
+ </li>
1484
+ </ul>
1485
+
1051
1486
  <h3>Custom Head HTML</h3>
1052
1487
  <p>
1053
1488
  Inject CDN links, fonts, or extra stylesheets into
1054
1489
  <code>&lt;head&gt;</code>. Example:
1055
1490
  </p>
1056
1491
  <pre>
1057
- &lt;link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"&gt;</pre
1492
+ &lt;link href="https://fonts.googleapis.com/css2?family=Inter&amp;display=swap" rel="stylesheet"&gt;</pre
1058
1493
  >
1494
+
1495
+ <h3>WebSocket Status</h3>
1496
+ <p>
1497
+ Optional <em>fromcubes</em> badge in the bottom-right corner showing
1498
+ connection state. Enable via the <strong>Show WebSocket status
1499
+ indicator</strong> checkbox (off by default).
1500
+ </p>
1501
+ </script>
1502
+
1503
+ <!-- ── Assets sidebar tab ──────────────────────────────────────── -->
1504
+ <script type="text/javascript">
1505
+ (function () {
1506
+ // Resolve httpNodeRoot for public URL prefix
1507
+ var nodeRoot = (RED.settings.httpNodeRoot || "/").replace(/\/$/, "");
1508
+ var publicBase = nodeRoot + "/fromcubes/public/";
1509
+
1510
+ function formatSize(bytes) {
1511
+ if (bytes < 1024) return bytes + " B";
1512
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
1513
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
1514
+ }
1515
+
1516
+ var allEntries = [];
1517
+ var collapsed = {};
1518
+
1519
+ var content = $('<div class="red-ui-sidebar-info" style="height:100%;overflow:auto;padding:0;"></div>');
1520
+ var fileList = $('<div style="padding:0;"></div>').appendTo(content);
1521
+
1522
+ // ── Toolbar ──
1523
+ var toolbar = $('<div style="display:flex;align-items:center;gap:6px;margin:0 6px;padding:2px 0 0;"></div>');
1524
+ var fileInput = $('<input type="file" multiple style="display:none;">').appendTo(toolbar);
1525
+ $('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-upload"></i> Upload</button>')
1526
+ .on("click", function (e) { e.preventDefault(); fileInput.trigger("click"); })
1527
+ .appendTo(toolbar);
1528
+ $('<button class="red-ui-button red-ui-button-small" style="flex-shrink:0;"><i class="fa fa-folder-open"></i> New folder</button>')
1529
+ .on("click", function (e) { e.preventDefault(); showNewFolderInput(""); })
1530
+ .appendTo(toolbar);
1531
+
1532
+ // ── Helpers ──
1533
+ function pathExists(p) {
1534
+ for (var i = 0; i < allEntries.length; i++) {
1535
+ if (allEntries[i].name === p) return true;
1536
+ }
1537
+ return false;
1538
+ }
1539
+
1540
+ function uploadFiles(files, targetDir) {
1541
+ if (!files || files.length === 0) return;
1542
+ var toUpload = [];
1543
+ var duplicates = [];
1544
+ for (var i = 0; i < files.length; i++) {
1545
+ var uploadPath = targetDir ? targetDir + "/" + files[i].name : files[i].name;
1546
+ if (pathExists(uploadPath)) {
1547
+ duplicates.push({ file: files[i], path: uploadPath });
1548
+ } else {
1549
+ toUpload.push({ file: files[i], path: uploadPath });
1550
+ }
1551
+ }
1552
+ if (duplicates.length > 0) {
1553
+ var names = duplicates.map(function (d) { return d.file.name; }).join(", ");
1554
+ if (confirm("These files already exist: " + names + "\nOverwrite?")) {
1555
+ toUpload = toUpload.concat(duplicates);
1556
+ }
1557
+ }
1558
+ if (toUpload.length === 0) return;
1559
+ var pending = toUpload.length;
1560
+ toUpload.forEach(function (item) {
1561
+ var reader = new FileReader();
1562
+ reader.onload = function () {
1563
+ $.ajax({
1564
+ type: "POST",
1565
+ url: "portal-react/assets/upload/" + item.path.split("/").map(encodeURIComponent).join("/"),
1566
+ data: new Uint8Array(reader.result),
1567
+ contentType: "application/octet-stream",
1568
+ processData: false,
1569
+ success: function () { if (--pending === 0) refreshList(); },
1570
+ error: function () {
1571
+ RED.notify("Upload failed: " + item.file.name, "error");
1572
+ if (--pending === 0) refreshList();
1573
+ },
1574
+ });
1575
+ };
1576
+ reader.readAsArrayBuffer(item.file);
1577
+ });
1578
+ }
1579
+
1580
+ fileInput.on("change", function () {
1581
+ uploadFiles(this.files, "");
1582
+ fileInput.val("");
1583
+ });
1584
+
1585
+ // External file drag & drop on content area (root upload)
1586
+ content.on("dragover", function (e) {
1587
+ if (e.originalEvent.dataTransfer.types.indexOf("Files") >= 0) {
1588
+ e.preventDefault();
1589
+ e.stopPropagation();
1590
+ content.css("background", "rgba(34,211,238,0.05)");
1591
+ }
1592
+ });
1593
+ content.on("dragleave", function (e) {
1594
+ e.preventDefault();
1595
+ content.css("background", "");
1596
+ });
1597
+ content.on("drop", function (e) {
1598
+ e.preventDefault();
1599
+ e.stopPropagation();
1600
+ content.css("background", "");
1601
+ var dt = e.originalEvent.dataTransfer;
1602
+ if (dt.files && dt.files.length > 0) {
1603
+ uploadFiles(dt.files, "");
1604
+ }
1605
+ });
1606
+
1607
+ function moveItem(fromPath, toDir) {
1608
+ var filename = fromPath.split("/").pop();
1609
+ var newPath = toDir ? toDir + "/" + filename : filename;
1610
+ if (fromPath === newPath) return;
1611
+ if (pathExists(newPath)) {
1612
+ if (!confirm("'" + filename + "' already exists in this folder. Overwrite?")) return;
1613
+ }
1614
+ $.ajax({
1615
+ type: "POST", url: "portal-react/assets/move",
1616
+ contentType: "application/json",
1617
+ data: JSON.stringify({ from: fromPath, to: newPath }),
1618
+ success: function () { refreshList(); },
1619
+ error: function (xhr) {
1620
+ var msg = xhr.responseJSON ? xhr.responseJSON.error : "move failed";
1621
+ RED.notify("Move failed: " + msg, "error");
1622
+ },
1623
+ });
1624
+ }
1625
+
1626
+ // ── Inline new-folder input ──
1627
+ function showNewFolderInput(parentDir) {
1628
+ var existingInput = fileList.find(".fc-new-folder-row");
1629
+ if (existingInput.length) existingInput.remove();
1630
+
1631
+ var depth = parentDir ? parentDir.split("/").length : 0;
1632
+ var indent = 8 + depth * 18;
1633
+ var 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>');
1634
+ row.css("padding-left", indent + "px");
1635
+ $('<i class="fa fa-folder" style="color:#fbbf24;font-size:13px;width:16px;text-align:center;"></i>').appendTo(row);
1636
+ var inp = $('<input type="text" placeholder="folder name" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">');
1637
+ inp.appendTo(row);
1638
+ var 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>');
1639
+ var cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
1640
+ function submit() {
1641
+ var name = inp.val().trim();
1642
+ if (!name) { row.remove(); return; }
1643
+ var p = parentDir ? parentDir + "/" + name : name;
1644
+ if (pathExists(p)) {
1645
+ RED.notify("Folder '" + name + "' already exists", "warning");
1646
+ return;
1647
+ }
1648
+ $.ajax({ type: "POST", url: "portal-react/assets/mkdir", contentType: "application/json", data: JSON.stringify({ path: p }),
1649
+ success: function () { row.remove(); refreshList(); },
1650
+ error: function () { RED.notify("Failed to create folder", "error"); },
1651
+ });
1652
+ }
1653
+ okBtn.on("click", function (e) { e.preventDefault(); submit(); });
1654
+ cancelBtn.on("click", function (e) { e.preventDefault(); row.remove(); });
1655
+ inp.on("keydown", function (e) {
1656
+ if (e.key === "Enter") submit();
1657
+ if (e.key === "Escape") row.remove();
1658
+ });
1659
+ okBtn.appendTo(row);
1660
+ cancelBtn.appendTo(row);
1661
+
1662
+ // Insert after the parent folder row, or at top
1663
+ if (parentDir) {
1664
+ var parentRow = fileList.find('[data-path="' + parentDir + '"]');
1665
+ if (parentRow.length) { row.insertAfter(parentRow); } else { fileList.prepend(row); }
1666
+ } else {
1667
+ fileList.prepend(row);
1668
+ }
1669
+ inp.focus();
1670
+ }
1671
+
1672
+ // ── Context menu (native Node-RED style) ──
1673
+ var activeMenu = null;
1674
+ function closeMenu() {
1675
+ if (activeMenu) { activeMenu.remove(); activeMenu = null; }
1676
+ }
1677
+ $(document).on("click", closeMenu);
1678
+
1679
+ function showMenu(anchor, items) {
1680
+ closeMenu();
1681
+ var menu = $('<ul class="red-ui-menu red-ui-menu-dropdown" style="position:absolute;z-index:10000;display:block;"></ul>');
1682
+ items.forEach(function (item) {
1683
+ if (item.divider) {
1684
+ menu.append('<li class="red-ui-menu-divider"></li>');
1685
+ return;
1686
+ }
1687
+ var li = $('<li></li>');
1688
+ var a = $('<a href="#"></a>');
1689
+ if (item.danger) a.css("color", "var(--red-ui-text-color-error)");
1690
+ a.append('<i class="fa ' + item.icon + '" style="width:18px;text-align:center;"></i> ');
1691
+ a.append($('<span class="red-ui-menu-label"></span>').text(item.label));
1692
+ a.on("click", function (e) {
1693
+ e.preventDefault();
1694
+ e.stopPropagation();
1695
+ closeMenu();
1696
+ item.action();
1697
+ });
1698
+ li.append(a);
1699
+ menu.append(li);
1700
+ });
1701
+
1702
+ $("body").append(menu);
1703
+ activeMenu = menu;
1704
+
1705
+ // Position near the anchor
1706
+ var off = anchor.offset();
1707
+ var top = off.top + anchor.outerHeight() + 2;
1708
+ var left = off.left - menu.outerWidth() + anchor.outerWidth();
1709
+ if (left < 0) left = off.left;
1710
+ if (top + menu.outerHeight() > $(window).height()) top = off.top - menu.outerHeight() - 2;
1711
+ menu.css({ top: top, left: left });
1712
+ }
1713
+
1714
+ var adminRoot = (RED.settings.httpAdminRoot || "/").replace(/\/$/, "");
1715
+
1716
+ function showRenameInput(rowEl, fullPath, currentName) {
1717
+ var nameSpan = rowEl.find("span").filter(function () { return $(this).text() === currentName; }).first();
1718
+ if (!nameSpan.length) return;
1719
+ var origText = nameSpan.text();
1720
+ var inp = $('<input type="text" class="red-ui-searchBox-input" style="flex:1;padding:3px 6px;font-size:12px;">').val(origText);
1721
+ var 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>');
1722
+ var cancelBtn = $('<button class="red-ui-button red-ui-button-small" title="Cancel"><i class="fa fa-times"></i></button>');
1723
+ var dotIdx = origText.lastIndexOf(".");
1724
+ var hasExt = dotIdx > 0; // has extension (not hidden file)
1725
+ var origExt = hasExt ? origText.slice(dotIdx) : "";
1726
+
1727
+ nameSpan.replaceWith(inp);
1728
+ inp.after(cancelBtn).after(okBtn);
1729
+ inp.focus();
1730
+ // Select only the name part before extension
1731
+ var el = inp[0];
1732
+ if (hasExt && el.setSelectionRange) {
1733
+ el.setSelectionRange(0, dotIdx);
1734
+ } else {
1735
+ inp.select();
1736
+ }
1737
+
1738
+ function restore() {
1739
+ okBtn.remove();
1740
+ cancelBtn.remove();
1741
+ inp.replaceWith($('<span style="flex:1;font-size:12px;word-break:break-all;"></span>').text(origText));
1742
+ }
1743
+ function submit() {
1744
+ var newName = inp.val().trim();
1745
+ if (!newName) {
1746
+ RED.notify("Name cannot be empty", "warning");
1747
+ return;
1748
+ }
1749
+ // Warn if extension changed or removed
1750
+ if (hasExt) {
1751
+ var newDot = newName.lastIndexOf(".");
1752
+ var newExt = newDot > 0 ? newName.slice(newDot) : "";
1753
+ if (newExt.toLowerCase() !== origExt.toLowerCase()) {
1754
+ if (!confirm("Extension changed from '" + origExt + "' to '" + (newExt || "none") + "'. Continue?")) return;
1755
+ }
1756
+ }
1757
+ if (newName === origText) { restore(); return; }
1758
+ var parentDir = fullPath.indexOf("/") >= 0 ? fullPath.slice(0, fullPath.lastIndexOf("/")) : "";
1759
+ var newPath = parentDir ? parentDir + "/" + newName : newName;
1760
+ if (pathExists(newPath)) {
1761
+ RED.notify("'" + newName + "' already exists", "warning");
1762
+ return;
1763
+ }
1764
+ $.ajax({
1765
+ type: "POST", url: "portal-react/assets/move",
1766
+ contentType: "application/json",
1767
+ data: JSON.stringify({ from: fullPath, to: newPath }),
1768
+ success: function () { refreshList(); },
1769
+ error: function (xhr) {
1770
+ var msg = xhr.responseJSON ? xhr.responseJSON.error : "rename failed";
1771
+ RED.notify("Rename failed: " + msg, "error");
1772
+ restore();
1773
+ },
1774
+ });
1775
+ }
1776
+ okBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); submit(); });
1777
+ cancelBtn.on("click", function (e) { e.preventDefault(); e.stopPropagation(); restore(); });
1778
+ inp.on("keydown", function (e) {
1779
+ if (e.key === "Enter") submit();
1780
+ if (e.key === "Escape") restore();
1781
+ });
1782
+ }
1783
+
1784
+ // ── Tree rendering ──
1785
+ function buildTree(entries) {
1786
+ // Build nested structure: { children: { name: { type, children, entry } } }
1787
+ var root = { children: {} };
1788
+ entries.forEach(function (e) {
1789
+ var parts = e.name.split("/");
1790
+ var node = root;
1791
+ for (var i = 0; i < parts.length; i++) {
1792
+ if (!node.children[parts[i]]) {
1793
+ node.children[parts[i]] = { children: {} };
1794
+ }
1795
+ node = node.children[parts[i]];
1796
+ }
1797
+ node.entry = e;
1798
+ });
1799
+ return root;
1800
+ }
1801
+
1802
+ var ROOT_KEY = "__root__";
1803
+
1804
+ function renderTree() {
1805
+ fileList.empty();
1806
+ var isOpen = !collapsed[ROOT_KEY];
1807
+
1808
+ // Root folder row — always visible
1809
+ var rootRow = $('<div style="display:flex;align-items:center;gap:5px;padding:4px 8px;border-bottom:1px solid var(--red-ui-secondary-border-color);"></div>');
1810
+ var 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>');
1811
+ rootArrow.on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); });
1812
+ rootRow.append(rootArrow);
1813
+ $('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(rootRow);
1814
+ $('<span style="flex:1;font-size:12px;cursor:pointer;opacity:0.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>').text(publicBase.replace(/\/$/, ""))
1815
+ .on("click", function () { collapsed[ROOT_KEY] = isOpen; renderTree(); })
1816
+ .appendTo(rootRow);
1817
+ fileList.append(rootRow);
1818
+
1819
+ if (!isOpen) return;
1820
+
1821
+ if (allEntries.length === 0) {
1822
+ 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>'));
1823
+ return;
1824
+ }
1825
+ var tree = buildTree(allEntries);
1826
+ renderNode(tree, "", 1);
1827
+ }
1828
+
1829
+ function renderNode(node, parentPath, depth) {
1830
+ // Collect and sort: dirs first, then files
1831
+ var names = Object.keys(node.children).sort(function (a, b) {
1832
+ var aIsDir = node.children[a].entry && node.children[a].entry.type === "dir";
1833
+ var bIsDir = node.children[b].entry && node.children[b].entry.type === "dir";
1834
+ if (aIsDir && !bIsDir) return -1;
1835
+ if (!aIsDir && bIsDir) return 1;
1836
+ return a.localeCompare(b);
1837
+ });
1838
+
1839
+ names.forEach(function (name) {
1840
+ var child = node.children[name];
1841
+ var e = child.entry;
1842
+ if (!e) return;
1843
+ var fullPath = e.name;
1844
+ var indent = 8 + depth * 18;
1845
+ var isDir = e.type === "dir";
1846
+ var isOpen = !collapsed[fullPath];
1847
+
1848
+ var 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>');
1849
+ row.css("padding-left", indent + "px");
1850
+
1851
+ if (isDir) {
1852
+ // Expand/collapse arrow
1853
+ var 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>');
1854
+ arrow.on("click", function (e) {
1855
+ e.stopPropagation();
1856
+ collapsed[fullPath] = isOpen;
1857
+ renderTree();
1858
+ });
1859
+ row.append(arrow);
1860
+ $('<i class="fa ' + (isOpen ? 'fa-folder-open' : 'fa-folder') + '" style="color:#fbbf24;font-size:12px;width:16px;text-align:center;"></i>').appendTo(row);
1861
+ $('<span style="flex:1;font-size:12px;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name)
1862
+ .on("click", function () { collapsed[fullPath] = isOpen; renderTree(); })
1863
+ .appendTo(row);
1864
+
1865
+ // Context menu trigger
1866
+ var dirMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
1867
+ (function (fp, nm) {
1868
+ dirMenuBtn.on("click", function (ev) {
1869
+ ev.preventDefault();
1870
+ ev.stopPropagation();
1871
+ showMenu(dirMenuBtn, [
1872
+ { icon: "fa-pencil", label: "Rename", action: function () {
1873
+ showRenameInput(row, fp, nm);
1874
+ }},
1875
+ { icon: "fa-plus", label: "New subfolder", action: function () {
1876
+ if (collapsed[fp]) { collapsed[fp] = false; renderTree(); }
1877
+ setTimeout(function () { showNewFolderInput(fp); }, 50);
1878
+ }},
1879
+ { divider: true },
1880
+ { icon: "fa-trash", label: "Delete folder", danger: true, action: function () {
1881
+ if (!confirm("Delete folder '" + nm + "' and all contents?")) return;
1882
+ $.ajax({
1883
+ type: "DELETE",
1884
+ url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
1885
+ success: function () { refreshList(); },
1886
+ error: function () { RED.notify("Delete failed", "error"); },
1887
+ });
1888
+ }},
1889
+ ]);
1890
+ });
1891
+ })(fullPath, name);
1892
+ dirMenuBtn.appendTo(row);
1893
+
1894
+ // Drop target
1895
+ row.on("dragover", function (ev) {
1896
+ ev.preventDefault();
1897
+ ev.stopPropagation();
1898
+ row.css("background", "rgba(251,191,36,0.08)");
1899
+ });
1900
+ row.on("dragleave", function () { row.css("background", ""); });
1901
+ row.on("drop", function (ev) {
1902
+ ev.preventDefault();
1903
+ ev.stopPropagation();
1904
+ row.css("background", "");
1905
+ var srcPath = ev.originalEvent.dataTransfer.getData("text/x-asset-path");
1906
+ if (srcPath) {
1907
+ moveItem(srcPath, fullPath);
1908
+ } else if (ev.originalEvent.dataTransfer.files && ev.originalEvent.dataTransfer.files.length > 0) {
1909
+ uploadFiles(ev.originalEvent.dataTransfer.files, fullPath);
1910
+ }
1911
+ });
1912
+ fileList.append(row);
1913
+
1914
+ // Render children if open
1915
+ if (isOpen) {
1916
+ renderNode(child, fullPath, depth + 1);
1917
+ }
1918
+ } else {
1919
+ // File
1920
+ row.attr("draggable", "true").css("cursor", "grab");
1921
+ $('<span style="width:10px;"></span>').appendTo(row); // spacer for arrow alignment
1922
+ $('<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);
1923
+ $('<span style="flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + fullPath + '"></span>').text(name).appendTo(row);
1924
+ $('<span style="font-size:10px;color:var(--red-ui-tertiary-text-color);white-space:nowrap;"></span>').text(formatSize(e.size)).appendTo(row);
1925
+
1926
+ row.on("dragstart", function (ev) {
1927
+ ev.originalEvent.dataTransfer.setData("text/x-asset-path", fullPath);
1928
+ ev.originalEvent.dataTransfer.effectAllowed = "move";
1929
+ row.css("opacity", "0.4");
1930
+ });
1931
+ row.on("dragend", function () { row.css("opacity", "1"); });
1932
+
1933
+ var 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>');
1934
+ var fileMenuBtn = $('<button class="red-ui-button red-ui-button-small" style="padding:1px 5px;"><i class="fa fa-ellipsis-v"></i></button>');
1935
+ (function (fp, nm) {
1936
+ copyBtn.on("click", function (ev) {
1937
+ ev.preventDefault();
1938
+ ev.stopPropagation();
1939
+ var url = publicBase + fp;
1940
+ navigator.clipboard.writeText(url).then(function () {
1941
+ RED.notify("Copied: " + url, { type: "success", timeout: 2000 });
1942
+ });
1943
+ });
1944
+ fileMenuBtn.on("click", function (ev) {
1945
+ ev.preventDefault();
1946
+ ev.stopPropagation();
1947
+ showMenu(fileMenuBtn, [
1948
+ { icon: "fa-pencil", label: "Rename", action: function () {
1949
+ showRenameInput(row, fp, nm);
1950
+ }},
1951
+ { icon: "fa-download", label: "Download", action: function () {
1952
+ var a = document.createElement("a");
1953
+ a.href = adminRoot + "/portal-react/assets/download/" + fp.split("/").map(encodeURIComponent).join("/");
1954
+ a.download = nm;
1955
+ document.body.appendChild(a);
1956
+ a.click();
1957
+ document.body.removeChild(a);
1958
+ }},
1959
+ { divider: true },
1960
+ { icon: "fa-trash", label: "Delete", danger: true, action: function () {
1961
+ $.ajax({
1962
+ type: "DELETE",
1963
+ url: "portal-react/assets/" + fp.split("/").map(encodeURIComponent).join("/"),
1964
+ success: function () { refreshList(); },
1965
+ error: function () { RED.notify("Delete failed: " + nm, "error"); },
1966
+ });
1967
+ }},
1968
+ ]);
1969
+ });
1970
+ })(fullPath, name);
1971
+ copyBtn.appendTo(row);
1972
+ fileMenuBtn.appendTo(row);
1973
+ fileList.append(row);
1974
+ }
1975
+ });
1976
+ }
1977
+
1978
+ function refreshList() {
1979
+ $.getJSON("portal-react/assets", function (entries) {
1980
+ allEntries = entries || [];
1981
+ renderTree();
1982
+ });
1983
+ }
1984
+
1985
+ RED.sidebar.addTab({
1986
+ id: "fromcubes-public",
1987
+ label: "Portal Assets",
1988
+ name: "fromcubes portal assets",
1989
+ iconClass: "fa fa-desktop",
1990
+ content: content[0],
1991
+ toolbar: toolbar[0],
1992
+ pinned: false,
1993
+ enableOnEdit: true,
1994
+ });
1995
+
1996
+ RED.actions.add("fromcubes:show-assets", function () {
1997
+ RED.sidebar.show("fromcubes-public");
1998
+ });
1999
+
2000
+ RED.events.on("sidebar:resize", function () { refreshList(); });
2001
+ setTimeout(refreshList, 1000);
2002
+ })();
1059
2003
  </script>