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

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